v0.21.0 scram password auth, cred and token management, browser tls-terminating proxy, viewer client
Test / test (push) Canceled after 0s
Test / test (push) Canceled after 0s
This commit is contained in:
1 parent
f192c26fa2
commit
9ab8c356d2
60 files changed
+6791
-747
No files matched your search
+388
-107
@@ -1,21 +1,25 @@
|
||||
// Package authz turns a verified certificate into an authorization decision.
|
||||
// It is the single seam between declarative auth config and the network
|
||||
// plugins: each one compiles a Policy at construction and calls Authorize per
|
||||
// connection (TCP) or per request (HTTP).
|
||||
//
|
||||
// New returns (nil, nil) when auth is disabled, mirroring tlsx.Server and
|
||||
// tlsx.Client, and every method tolerates a nil receiver — so call sites read
|
||||
// the same whether or not a policy is configured.
|
||||
// Package authz is the single seam between declarative auth config and the
|
||||
// network plugins. Listeners admit peers through Admit (TCP) or
|
||||
// AuthorizeRequest (HTTP); dialers prove themselves through Greet or Prepare.
|
||||
// New returns nil when auth is disabled and every method tolerates a nil
|
||||
// receiver, so call sites read the same whether or not a policy is set.
|
||||
package authz
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"regexp/syntax"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/chain"
|
||||
"logwisp/internal/config"
|
||||
"logwisp/internal/tlsx"
|
||||
|
||||
@@ -24,8 +28,9 @@ import (
|
||||
|
||||
// Authentication methods
|
||||
const (
|
||||
MethodNone = "none"
|
||||
MethodMTLS = "mtls"
|
||||
MethodNone = "none"
|
||||
MethodMTLS = "mtls"
|
||||
MethodSCRAM = "scram"
|
||||
)
|
||||
|
||||
// Node label binding modes. See ResolveNode and TrustsEntryNode for the
|
||||
@@ -40,35 +45,56 @@ const (
|
||||
type Role int
|
||||
|
||||
const (
|
||||
// RoleListener authorizes client certificates on a plugin with no node
|
||||
// concept: the tcp and http sinks
|
||||
// RoleListener authenticates peers of a plugin with no node concept: the
|
||||
// tcp and http sinks
|
||||
RoleListener Role = iota
|
||||
// RoleChainListener authorizes client certificates and binds the node
|
||||
// label a peer declares: the tcp_chain and http_chain sources
|
||||
// RoleChainListener also binds the node label a peer declares: the
|
||||
// tcp_chain and http_chain sources
|
||||
RoleChainListener
|
||||
// RoleDialer pins the server's identity beyond hostname verification:
|
||||
// the tcp_chain and http_chain sinks
|
||||
// RoleDialer proves itself to, or pins, the server: the tcp_chain and
|
||||
// http_chain sinks
|
||||
RoleDialer
|
||||
)
|
||||
|
||||
// Transport tells HTTP plugins, which carry bearer tokens, from TCP ones
|
||||
type Transport int
|
||||
|
||||
const (
|
||||
TCP Transport = iota
|
||||
HTTP
|
||||
)
|
||||
|
||||
// ErrRefused marks a decision of the policy, on either side: a peer refused
|
||||
// by this listener, or this dialer refused by its server. Anything else is a
|
||||
// transport or protocol failure.
|
||||
var ErrRefused = errors.New("auth: refused")
|
||||
|
||||
// Policy is the compiled form of config.AuthOptions
|
||||
type Policy struct {
|
||||
role Role
|
||||
identity string
|
||||
allow map[string]struct{}
|
||||
patterns []*regexp.Regexp
|
||||
binding string
|
||||
role Role
|
||||
transport Transport
|
||||
method string
|
||||
identity string // mtls: identity field; scram: certificate-to-user binding, "" = none
|
||||
allow map[string]struct{}
|
||||
patterns []*regexp.Regexp
|
||||
binding string
|
||||
secrets []secretFile // reported at startup when every local user can read them
|
||||
|
||||
listener *scramListener // scram listener state
|
||||
dialer *scramDialer // scram dialer state
|
||||
|
||||
// Statistics
|
||||
allowed atomic.Uint64
|
||||
rejected atomic.Uint64
|
||||
}
|
||||
|
||||
type secretFile struct{ key, path string }
|
||||
|
||||
// Identity is the outcome of a successful authorization. The zero value is
|
||||
// what a disabled policy yields.
|
||||
type Identity struct {
|
||||
Name string // the selected certificate field
|
||||
Method string // MethodMTLS
|
||||
Name string // certificate field (mtls) or username (scram)
|
||||
Method string
|
||||
}
|
||||
|
||||
// Apply stamps an authenticated identity onto session metadata. A zero
|
||||
@@ -82,69 +108,100 @@ func (id Identity) Apply(meta map[string]any) {
|
||||
}
|
||||
|
||||
// New compiles an auth policy, returning (nil, nil) when auth is disabled.
|
||||
// tlsOpts is the sibling `tls` block: an auth policy the transport cannot
|
||||
// enforce is rejected here rather than silently accepted, which is the
|
||||
// failure mode worth designing out.
|
||||
func New(o *config.AuthOptions, tlsOpts *config.TLSOptions, role Role) (*Policy, error) {
|
||||
// tlsCfg is what tlsx built from the sibling `tls` block: a policy the
|
||||
// transport cannot enforce is rejected here rather than silently accepted.
|
||||
func New(o *config.AuthOptions, tlsCfg *tls.Config, role Role, transport Transport) (*Policy, error) {
|
||||
if o == nil {
|
||||
return nil, nil
|
||||
}
|
||||
switch o.Type {
|
||||
case "", MethodNone:
|
||||
// Tuning keys are ignored with the block, but one that names peers or
|
||||
// credentials means auth was intended and the type was forgotten
|
||||
if key := intentKey(o); key != "" {
|
||||
return nil, fmt.Errorf("auth: %s is set but auth.type is %q", key, MethodNone)
|
||||
}
|
||||
return nil, nil
|
||||
case MethodMTLS:
|
||||
case MethodMTLS, MethodSCRAM:
|
||||
default:
|
||||
return nil, fmt.Errorf("auth: type %q (valid: %q, %q)", o.Type, MethodNone, MethodMTLS)
|
||||
return nil, fmt.Errorf("auth: type %q (valid: %q, %q, %q)", o.Type, MethodNone, MethodMTLS, MethodSCRAM)
|
||||
}
|
||||
if tlsCfg == nil && (o.Type != MethodSCRAM || len(o.TrustedProxies) == 0) {
|
||||
return nil, fmt.Errorf("auth: type %q requires tls.enabled", o.Type)
|
||||
}
|
||||
if role == RoleDialer && tlsCfg != nil && tlsCfg.InsecureSkipVerify {
|
||||
// An unverified server makes identities claims and exposes credentials
|
||||
return nil, fmt.Errorf("auth: type %q cannot be used with tls.insecure_skip_verify", o.Type)
|
||||
}
|
||||
|
||||
if tlsOpts == nil || !tlsOpts.Enabled {
|
||||
return nil, fmt.Errorf("auth: type %q requires tls.enabled", MethodMTLS)
|
||||
binding, err := nodeBinding(o.NodeBinding, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == RoleDialer {
|
||||
// Identity from an unverified chain is a claim, not a fact
|
||||
if tlsOpts.InsecureSkipVerify {
|
||||
return nil, fmt.Errorf("auth: type %q cannot pin an identity with tls.insecure_skip_verify", MethodMTLS)
|
||||
}
|
||||
} else if !tlsOpts.ClientAuth {
|
||||
return nil, fmt.Errorf("auth: type %q requires tls.client_auth", MethodMTLS)
|
||||
}
|
||||
|
||||
identity := o.Identity
|
||||
if identity == "" {
|
||||
identity = tlsx.IdentityCN
|
||||
}
|
||||
switch identity {
|
||||
case tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail:
|
||||
default:
|
||||
return nil, fmt.Errorf("auth: identity %q (valid: %q, %q, %q, %q)",
|
||||
identity, tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail)
|
||||
}
|
||||
|
||||
binding := o.NodeBinding
|
||||
if role == RoleChainListener {
|
||||
if binding == "" {
|
||||
// The only setting under which a misconfigured or hostile edge
|
||||
// cannot mislabel its entries
|
||||
binding = BindingForce
|
||||
}
|
||||
} else if binding != "" && binding != BindingNone {
|
||||
return nil, fmt.Errorf("auth: node_binding %q applies only to chain sources", binding)
|
||||
p := &Policy{role: role, transport: transport, method: o.Type, binding: binding}
|
||||
if o.Type == MethodMTLS {
|
||||
err = p.compileMTLS(o, tlsCfg)
|
||||
} else {
|
||||
binding = BindingNone
|
||||
err = p.compileSCRAM(o, tlsCfg)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// intentKey names the first key that only makes sense with authentication on
|
||||
func intentKey(o *config.AuthOptions) string {
|
||||
switch {
|
||||
case len(o.Allow) > 0:
|
||||
return "allow"
|
||||
case len(o.AllowPatterns) > 0:
|
||||
return "allow_patterns"
|
||||
}
|
||||
return scramKey(o)
|
||||
}
|
||||
|
||||
func nodeBinding(binding string, role Role) (string, error) {
|
||||
if role != RoleChainListener {
|
||||
if binding != "" && binding != BindingNone {
|
||||
return "", fmt.Errorf("auth: node_binding %q applies only to chain sources", binding)
|
||||
}
|
||||
return BindingNone, nil
|
||||
}
|
||||
switch binding {
|
||||
case "":
|
||||
// The only setting under which a misconfigured or hostile edge cannot
|
||||
// mislabel its entries
|
||||
return BindingForce, nil
|
||||
case BindingNone, BindingAssert, BindingForce:
|
||||
default:
|
||||
return nil, fmt.Errorf("auth: node_binding %q (valid: %q, %q, %q)",
|
||||
binding, BindingNone, BindingAssert, BindingForce)
|
||||
return binding, nil
|
||||
}
|
||||
return "", fmt.Errorf("auth: node_binding %q (valid: %q, %q, %q)", binding, BindingNone, BindingAssert, BindingForce)
|
||||
}
|
||||
|
||||
p := &Policy{
|
||||
role: role,
|
||||
identity: identity,
|
||||
binding: binding,
|
||||
allow: make(map[string]struct{}, len(o.Allow)),
|
||||
func identityMode(mode string) (string, error) {
|
||||
switch mode {
|
||||
case "":
|
||||
return tlsx.IdentityCN, nil
|
||||
case tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail:
|
||||
return mode, nil
|
||||
}
|
||||
return "", fmt.Errorf("auth: identity %q (valid: %q, %q, %q, %q)",
|
||||
mode, tlsx.IdentityCN, tlsx.IdentitySANDNS, tlsx.IdentitySANURI, tlsx.IdentitySANEmail)
|
||||
}
|
||||
|
||||
func (p *Policy) compileMTLS(o *config.AuthOptions, tlsCfg *tls.Config) error {
|
||||
if key := scramKey(o); key != "" {
|
||||
return fmt.Errorf("auth: %s applies only to type %q", key, MethodSCRAM)
|
||||
}
|
||||
if p.role != RoleDialer && tlsCfg.ClientAuth != tls.RequireAndVerifyClientCert {
|
||||
return fmt.Errorf("auth: type %q requires tls.client_auth", MethodMTLS)
|
||||
}
|
||||
var err error
|
||||
if p.identity, err = identityMode(o.Identity); err != nil {
|
||||
return err
|
||||
}
|
||||
p.allow = make(map[string]struct{}, len(o.Allow))
|
||||
for _, a := range o.Allow {
|
||||
if a = strings.TrimSpace(a); a != "" {
|
||||
p.allow[a] = struct{}{}
|
||||
@@ -153,47 +210,236 @@ func New(o *config.AuthOptions, tlsOpts *config.TLSOptions, role Role) (*Policy,
|
||||
for i, pat := range o.AllowPatterns {
|
||||
re, err := regexp.Compile(pat)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: allow_patterns[%d] %q: %w", i, pat, err)
|
||||
return fmt.Errorf("auth: allow_patterns[%d] %q: %w", i, pat, err)
|
||||
}
|
||||
p.patterns = append(p.patterns, re)
|
||||
}
|
||||
return p, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authorize extracts and checks the peer identity from a completed handshake.
|
||||
// A nil policy authorizes everything and yields the zero Identity, so callers
|
||||
// need no branch on whether auth is configured.
|
||||
func scramKey(o *config.AuthOptions) string {
|
||||
switch {
|
||||
case o.CredentialsFile != "":
|
||||
return "credentials_file"
|
||||
case o.TokenLifetimeMS != 0:
|
||||
return "token_lifetime_ms"
|
||||
case o.Username != "":
|
||||
return "username"
|
||||
case o.PasswordFile != "":
|
||||
return "password_file"
|
||||
case len(o.TrustedProxies) > 0:
|
||||
return "trusted_proxies"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Authorize checks the mtls certificate identity of a completed handshake. It
|
||||
// refuses under scram, so a listener that skipped its exchange fails closed.
|
||||
// A nil policy authorizes everything and yields the zero Identity.
|
||||
func (p *Policy) Authorize(cs *tls.ConnectionState) (Identity, error) {
|
||||
if p == nil {
|
||||
return Identity{}, nil
|
||||
}
|
||||
if p.method != MethodMTLS {
|
||||
p.rejected.Add(1)
|
||||
return Identity{}, fmt.Errorf("%w: type %q admits peers only through its exchange", ErrRefused, p.method)
|
||||
}
|
||||
if cs == nil {
|
||||
p.rejected.Add(1)
|
||||
return Identity{}, fmt.Errorf("auth: peer is not on a TLS connection")
|
||||
return Identity{}, fmt.Errorf("%w: peer is not on a TLS connection", ErrRefused)
|
||||
}
|
||||
name := tlsx.PeerIdentity(*cs, p.identity)
|
||||
if name == "" {
|
||||
// An unusable identity field is a rejection, not an empty match
|
||||
p.rejected.Add(1)
|
||||
return Identity{}, fmt.Errorf("auth: peer certificate carries no %s identity", p.identity)
|
||||
return Identity{}, fmt.Errorf("%w: peer certificate carries no %s identity", ErrRefused, p.identity)
|
||||
}
|
||||
if !p.permits(name) {
|
||||
p.rejected.Add(1)
|
||||
return Identity{}, fmt.Errorf("auth: identity %q is not allowed", name)
|
||||
return Identity{}, fmt.Errorf("%w: identity %q is not allowed", ErrRefused, name)
|
||||
}
|
||||
p.allowed.Add(1)
|
||||
return Identity{Name: name, Method: MethodMTLS}, nil
|
||||
}
|
||||
|
||||
// VerifyConnection is assignable to tls.Config.VerifyConnection on a dialer,
|
||||
// so a server whose identity the policy rejects fails the handshake itself
|
||||
// rather than after the first write. It runs after the standard chain and
|
||||
// hostname checks, so the identity it reads is already verified.
|
||||
// VerifyConnection is assignable to tls.Config.VerifyConnection on a dialer.
|
||||
// It runs after the standard chain and hostname checks on every connection,
|
||||
// resumed ones included: mtls pins the server identity, scram pins the
|
||||
// certificate its last exchange was bound to.
|
||||
func (p *Policy) VerifyConnection(cs tls.ConnectionState) error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if p.dialer != nil {
|
||||
return p.dialer.verifyPin(cs)
|
||||
}
|
||||
_, err := p.Authorize(&cs)
|
||||
return err
|
||||
}
|
||||
|
||||
// Admission is a TCP peer that passed authentication and awaits the plugin's
|
||||
// own checks. Under scram nothing is sent until Accept or Reject, so a later
|
||||
// check such as node binding still decides what the peer is told.
|
||||
type Admission struct {
|
||||
Identity Identity
|
||||
Hello chain.Hello // zero when no hello was read
|
||||
Reader *bufio.Reader // the stream continues here, after the auth lines
|
||||
conn net.Conn
|
||||
final []byte // scram: the server-final line Accept sends
|
||||
deadline bool
|
||||
}
|
||||
|
||||
// Admit authenticates a TCP peer before it is served: the certificate under
|
||||
// mtls (before anything is read), the hello's SCRAM exchange under scram.
|
||||
// wantHello reads the chain hello without scram too. The whole exchange runs
|
||||
// under one read/write deadline of timeout; Accept clears it.
|
||||
func (p *Policy) Admit(conn net.Conn, cs *tls.ConnectionState, wantHello bool, timeout time.Duration) (*Admission, error) {
|
||||
a := &Admission{conn: conn, Reader: bufio.NewReaderSize(conn, streamBuffer)}
|
||||
scram := p != nil && p.listener != nil
|
||||
if !scram {
|
||||
id, err := p.Authorize(cs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.Identity = id
|
||||
}
|
||||
if !wantHello && !scram {
|
||||
return a, nil
|
||||
}
|
||||
conn.SetDeadline(time.Now().Add(timeout))
|
||||
a.deadline = true
|
||||
line, err := readLine(a.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read hello: %w", err)
|
||||
}
|
||||
if a.Hello, err = chain.DecodeHello(line); err != nil {
|
||||
if scram {
|
||||
p.rejected.Add(1)
|
||||
writeStep(conn, authStep{Error: "malformed hello"})
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case scram:
|
||||
if len(a.Hello.Scram) == 0 {
|
||||
p.rejected.Add(1)
|
||||
writeStep(conn, authStep{Error: "authentication required"})
|
||||
return nil, fmt.Errorf("%w: peer offered no credentials", ErrRefused)
|
||||
}
|
||||
a.Identity, a.final, err = p.exchangeTCP(a, cs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case len(a.Hello.Scram) > 0:
|
||||
// Without this answer a SCRAM dialer would wait out its deadline
|
||||
writeStep(conn, authStep{Error: "authentication not enabled"})
|
||||
return nil, fmt.Errorf("%w: peer offered credentials, auth.type is not %q", ErrRefused, MethodSCRAM)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Accept tells a scram peer it is admitted and lifts the exchange deadline
|
||||
func (a *Admission) Accept() error {
|
||||
if a.final != nil {
|
||||
if _, err := a.conn.Write(a.final); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if a.deadline {
|
||||
return a.conn.SetDeadline(time.Time{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject tells a scram peer why it is turned away after authenticating
|
||||
func (a *Admission) Reject(reason string) {
|
||||
if a.final != nil {
|
||||
writeStep(a.conn, authStep{Error: reason})
|
||||
}
|
||||
}
|
||||
|
||||
// AuthorizeRequest admits one HTTP request to a protected endpoint: the
|
||||
// client certificate under mtls, the bearer token under scram. status is the
|
||||
// answer for a refusal (401 or 403); see Refuse.
|
||||
func (p *Policy) AuthorizeRequest(r *http.Request) (Identity, int, error) {
|
||||
switch {
|
||||
case p == nil:
|
||||
return Identity{}, 0, nil
|
||||
case p.listener == nil:
|
||||
id, err := p.Authorize(r.TLS)
|
||||
if err != nil {
|
||||
return Identity{}, http.StatusForbidden, err
|
||||
}
|
||||
return id, 0, nil
|
||||
}
|
||||
return p.authorizeToken(r)
|
||||
}
|
||||
|
||||
// Refuse answers a request AuthorizeRequest turned away, with no detail
|
||||
func Refuse(w http.ResponseWriter, status int) {
|
||||
if status == http.StatusUnauthorized {
|
||||
w.Header().Set("WWW-Authenticate", `Bearer realm="logwisp"`)
|
||||
}
|
||||
http.Error(w, strings.ToLower(http.StatusText(status)), status)
|
||||
}
|
||||
|
||||
// Greet opens a dialer's connection: it writes the hello and, under scram,
|
||||
// runs the whole exchange under ExchangeTimeout, cut short by ctx. The
|
||||
// returned reader holds whatever the server sent after the exchange.
|
||||
func (p *Policy) Greet(ctx context.Context, conn net.Conn, node string) (*bufio.Reader, error) {
|
||||
r := bufio.NewReaderSize(conn, streamBuffer)
|
||||
conn.SetDeadline(time.Now().Add(ExchangeTimeout))
|
||||
defer context.AfterFunc(ctx, func() { conn.SetDeadline(time.Now()) })()
|
||||
if p == nil || p.dialer == nil {
|
||||
line, err := chain.EncodeHello(chain.Hello{Node: node})
|
||||
if err == nil {
|
||||
_, err = conn.Write(line)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("hello: %w", err)
|
||||
}
|
||||
} else if err := p.dialer.exchangeTCP(conn, r, node); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return r, conn.SetDeadline(time.Time{})
|
||||
}
|
||||
|
||||
// Prepare readies a request for a protected HTTP endpoint: under scram it
|
||||
// authenticates when no token is held, then sets the bearer header. baseURL
|
||||
// is scheme://host:port; client must carry VerifyConnection. No-op otherwise.
|
||||
func (p *Policy) Prepare(ctx context.Context, client *http.Client, baseURL string, req *http.Request) error {
|
||||
if p == nil || p.dialer == nil {
|
||||
return nil
|
||||
}
|
||||
token := p.dialer.token.Load()
|
||||
if token == nil || time.Now().UnixNano() >= p.dialer.renewAt.Load() {
|
||||
t, err := p.Token(ctx, client, baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
token = &t
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+*token)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Invalidate drops the scram token after the server refused it (status 401)
|
||||
// or a connection presented a certificate other than the bound one (err),
|
||||
// and reports whether a retry can succeed by authenticating again.
|
||||
func (p *Policy) Invalidate(status int, err error) bool {
|
||||
if p == nil || p.dialer == nil {
|
||||
return false
|
||||
}
|
||||
if status != http.StatusUnauthorized && !errors.Is(err, errPinMismatch) {
|
||||
return false
|
||||
}
|
||||
p.dialer.dropToken()
|
||||
return true
|
||||
}
|
||||
|
||||
// permits reports whether an identity satisfies the allow list. An empty list
|
||||
// admits any identity the CA vouches for; that is the documented default, and
|
||||
// constructors log it at startup rather than leaving it silent.
|
||||
@@ -272,20 +518,42 @@ func (p *Policy) NodeBinding() string {
|
||||
// Enabled reports whether a policy is in force
|
||||
func (p *Policy) Enabled() bool { return p != nil }
|
||||
|
||||
// Unrestricted reports whether the policy admits any identity the CA vouches
|
||||
// for. Constructors log this at startup: it is a deliberate default, and a
|
||||
// silent one would be a footgun.
|
||||
// Unrestricted reports whether an mtls policy admits any identity the CA
|
||||
// vouches for. Under scram the credentials file is the allow list.
|
||||
func (p *Policy) Unrestricted() bool {
|
||||
return p != nil && len(p.allow) == 0 && len(p.patterns) == 0
|
||||
return p != nil && p.method == MethodMTLS && len(p.allow) == 0 && len(p.patterns) == 0
|
||||
}
|
||||
|
||||
// LogStartup reports, once per plugin construction, what a listener policy
|
||||
// admits, how it labels nodes, and allow patterns that admit more than they
|
||||
// appear to. trustNode matters only for chain sources.
|
||||
// LogStartup reports, once per plugin construction, what the policy admits,
|
||||
// how it labels nodes, allow patterns that admit more than they appear to,
|
||||
// and secret files every local user can read. trustNode: chain sources only.
|
||||
func (p *Policy) LogStartup(l *log.Logger, component, id string, trustNode bool) {
|
||||
if p == nil || p.role == RoleDialer {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
for _, re := range p.patterns {
|
||||
if !anchored(re.String()) {
|
||||
l.Warn("msg", "auth.allow_patterns entry is not anchored and matches any identity containing it",
|
||||
"component", component,
|
||||
"instance_id", id,
|
||||
"pattern", re.String(),
|
||||
"hint", "anchor every alternative, e.g. ^(edge-01|edge-02)$")
|
||||
}
|
||||
}
|
||||
for _, f := range p.secrets {
|
||||
if w := tlsx.SecretFileWarning(f.key, f.path); w != "" {
|
||||
l.Warn("msg", w, "component", component, "instance_id", id)
|
||||
}
|
||||
}
|
||||
if p.role == RoleDialer {
|
||||
return
|
||||
}
|
||||
if p.BehindProxy() && p.listener.proxy.exposedHop() {
|
||||
l.Warn("msg", "Trusted proxies may be on other hosts and the hop from them is plaintext: anyone on that path can relay logins and read sessions",
|
||||
"component", component,
|
||||
"instance_id", id,
|
||||
"hint", "keep the proxies on loopback, or enable tls on this listener")
|
||||
}
|
||||
if p.Unrestricted() {
|
||||
l.Warn("msg", "Auth policy admits any identity the configured CA vouches for",
|
||||
"component", component,
|
||||
@@ -299,15 +567,6 @@ func (p *Policy) LogStartup(l *log.Logger, component, id string, trustNode bool)
|
||||
"node_binding", p.binding,
|
||||
"trust_node", trustNode)
|
||||
}
|
||||
for _, re := range p.patterns {
|
||||
if !anchored(re.String()) {
|
||||
l.Warn("msg", "auth.allow_patterns entry is not anchored and matches any identity containing it",
|
||||
"component", component,
|
||||
"instance_id", id,
|
||||
"pattern", re.String(),
|
||||
"hint", "anchor every alternative, e.g. ^(edge-01|edge-02)$")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// anchored reports whether every match of pattern spans the whole identity:
|
||||
@@ -348,15 +607,26 @@ func anchoredAt(re *syntax.Regexp, anchor syntax.Op) bool {
|
||||
|
||||
// Describe renders the policy for a startup log line
|
||||
func (p *Policy) Describe() string {
|
||||
if p == nil {
|
||||
switch {
|
||||
case p == nil:
|
||||
return MethodNone
|
||||
case p.dialer != nil:
|
||||
return fmt.Sprintf("%s user=%s", MethodSCRAM, p.dialer.username)
|
||||
case p.listener != nil:
|
||||
cert := "independent"
|
||||
if p.identity != "" {
|
||||
cert = p.identity + "=username"
|
||||
}
|
||||
if p.BehindProxy() {
|
||||
return fmt.Sprintf("%s users=%d unbound behind %d trusted proxy range(s)", MethodSCRAM, len(p.listener.creds.Users), len(p.listener.proxy.trusted))
|
||||
}
|
||||
return fmt.Sprintf("%s users=%d certificate=%s node_binding=%s", MethodSCRAM, len(p.listener.creds.Users), cert, p.binding)
|
||||
}
|
||||
scope := fmt.Sprintf("%d exact, %d pattern(s)", len(p.allow), len(p.patterns))
|
||||
if p.Unrestricted() {
|
||||
scope = "any identity issued by the configured CA"
|
||||
}
|
||||
return fmt.Sprintf("%s identity=%s allow=[%s] node_binding=%s",
|
||||
MethodMTLS, p.identity, scope, p.binding)
|
||||
return fmt.Sprintf("%s identity=%s allow=[%s] node_binding=%s", MethodMTLS, p.identity, scope, p.binding)
|
||||
}
|
||||
|
||||
// Rejected returns the number of authorization failures
|
||||
@@ -369,17 +639,28 @@ func (p *Policy) Rejected() uint64 {
|
||||
|
||||
// Stats reports policy state for a plugin's stats details map. Merge it in
|
||||
// with maps.Copy so rejections surface in the status reporter and in the
|
||||
// http sink's status endpoint.
|
||||
// http sink's status endpoint. allowed counts admissions (certificate checks,
|
||||
// SCRAM logins); rejected counts every refusal.
|
||||
func (p *Policy) Stats() map[string]any {
|
||||
if p == nil {
|
||||
return map[string]any{"auth": MethodNone}
|
||||
}
|
||||
d := map[string]any{
|
||||
"auth": MethodMTLS,
|
||||
"auth_identity": p.identity,
|
||||
"auth_unrestricted": p.Unrestricted(),
|
||||
"auth_allowed": p.allowed.Load(),
|
||||
"auth_rejected": p.rejected.Load(),
|
||||
"auth": p.method,
|
||||
"auth_allowed": p.allowed.Load(),
|
||||
"auth_rejected": p.rejected.Load(),
|
||||
}
|
||||
switch {
|
||||
case p.dialer != nil:
|
||||
p.dialer.stats(d)
|
||||
case p.listener != nil:
|
||||
p.listener.stats(d)
|
||||
if p.identity != "" {
|
||||
d["auth_identity"] = p.identity
|
||||
}
|
||||
default:
|
||||
d["auth_identity"] = p.identity
|
||||
d["auth_unrestricted"] = p.Unrestricted()
|
||||
}
|
||||
if p.role == RoleChainListener {
|
||||
d["node_binding"] = p.binding
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"logwisp/internal/config"
|
||||
@@ -21,8 +22,8 @@ func leafCN(cn string) *x509.Certificate {
|
||||
return &x509.Certificate{Subject: pkix.Name{CommonName: cn}}
|
||||
}
|
||||
|
||||
func mtlsListenerTLS() *config.TLSOptions {
|
||||
return &config.TLSOptions{Enabled: true, ClientAuth: true}
|
||||
func mtlsListenerTLS() *tls.Config {
|
||||
return &tls.Config{ClientAuth: tls.RequireAndVerifyClientCert}
|
||||
}
|
||||
|
||||
func TestPeerIdentityModes(t *testing.T) {
|
||||
@@ -65,7 +66,7 @@ func TestPeerIdentityModes(t *testing.T) {
|
||||
|
||||
func TestNewDisabled(t *testing.T) {
|
||||
for _, o := range []*config.AuthOptions{nil, {}, {Type: MethodNone}} {
|
||||
p, err := New(o, nil, RoleListener)
|
||||
p, err := New(o, nil, RoleListener, TCP)
|
||||
if err != nil {
|
||||
t.Fatalf("New(%+v) error: %v", o, err)
|
||||
}
|
||||
@@ -106,35 +107,78 @@ func TestNilPolicyIsTransparent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Each row is valid but for its own rule, and the error names that rule
|
||||
func TestNewValidation(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
pw := f.write(t, "pw", "edge-01-secret\n")
|
||||
scram := func(o config.AuthOptions) *config.AuthOptions { o.Type = MethodSCRAM; return &o }
|
||||
listener := func(o config.AuthOptions) *config.AuthOptions { o.CredentialsFile = f.creds; return scram(o) }
|
||||
proxies := []string{"127.0.0.1", "10.0.0.0/8"}
|
||||
dialer := func(o config.AuthOptions) *config.AuthOptions {
|
||||
o.Username, o.PasswordFile = "edge-01", pw
|
||||
return scram(o)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
auth *config.AuthOptions
|
||||
tls *config.TLSOptions
|
||||
role Role
|
||||
name string
|
||||
auth *config.AuthOptions
|
||||
tls *tls.Config
|
||||
role Role
|
||||
transport Transport
|
||||
want string
|
||||
}{
|
||||
{"unknown type", &config.AuthOptions{Type: "kerberos"}, mtlsListenerTLS(), RoleListener},
|
||||
{"no tls", &config.AuthOptions{Type: MethodMTLS}, nil, RoleListener},
|
||||
{"tls disabled", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{}, RoleListener},
|
||||
{"no client_auth", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true}, RoleListener},
|
||||
{"unknown identity", &config.AuthOptions{Type: MethodMTLS, Identity: "serial"}, mtlsListenerTLS(), RoleListener},
|
||||
{"bad pattern", &config.AuthOptions{Type: MethodMTLS, AllowPatterns: []string{"^edge-("}}, mtlsListenerTLS(), RoleListener},
|
||||
{"unknown binding", &config.AuthOptions{Type: MethodMTLS, NodeBinding: "maybe"}, mtlsListenerTLS(), RoleChainListener},
|
||||
{"binding on plain listener", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, mtlsListenerTLS(), RoleListener},
|
||||
{"binding on dialer", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, &config.TLSOptions{Enabled: true}, RoleDialer},
|
||||
{"dialer skips verify", &config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true, InsecureSkipVerify: true}, RoleDialer},
|
||||
{"unknown type", &config.AuthOptions{Type: "kerberos"}, mtlsListenerTLS(), RoleListener, TCP, `type "kerberos"`},
|
||||
{"allow list without a type", &config.AuthOptions{Allow: []string{"edge-01"}}, mtlsListenerTLS(), RoleListener, TCP, "allow is set"},
|
||||
{"credentials without a type", &config.AuthOptions{CredentialsFile: f.creds}, mtlsListenerTLS(), RoleListener, TCP, "credentials_file is set"},
|
||||
{"no tls", &config.AuthOptions{Type: MethodMTLS}, nil, RoleListener, TCP, "requires tls.enabled"},
|
||||
{"no client_auth", &config.AuthOptions{Type: MethodMTLS}, &tls.Config{}, RoleListener, TCP, "requires tls.client_auth"},
|
||||
{"unknown identity", &config.AuthOptions{Type: MethodMTLS, Identity: "serial"}, mtlsListenerTLS(), RoleListener, TCP, `identity "serial"`},
|
||||
{"bad pattern", &config.AuthOptions{Type: MethodMTLS, AllowPatterns: []string{"^edge-("}}, mtlsListenerTLS(), RoleListener, TCP, "allow_patterns[0]"},
|
||||
{"unknown binding", &config.AuthOptions{Type: MethodMTLS, NodeBinding: "maybe"}, mtlsListenerTLS(), RoleChainListener, TCP, `node_binding "maybe"`},
|
||||
{"binding on plain listener", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, mtlsListenerTLS(), RoleListener, TCP, "only to chain sources"},
|
||||
{"binding on dialer", &config.AuthOptions{Type: MethodMTLS, NodeBinding: BindingForce}, &tls.Config{}, RoleDialer, TCP, "only to chain sources"},
|
||||
{"dialer skips verify", &config.AuthOptions{Type: MethodMTLS}, &tls.Config{InsecureSkipVerify: true}, RoleDialer, TCP, "insecure_skip_verify"},
|
||||
{"mtls with a password", &config.AuthOptions{Type: MethodMTLS, PasswordFile: pw}, &tls.Config{}, RoleDialer, TCP, "password_file applies only"},
|
||||
{"scram without tls", listener(config.AuthOptions{}), nil, RoleListener, TCP, "requires tls.enabled"},
|
||||
{"scram without credentials", scram(config.AuthOptions{}), f.serverTLS, RoleListener, TCP, "requires credentials_file"},
|
||||
{"scram with an allow list", listener(config.AuthOptions{Allow: []string{"edge-01"}}), f.serverTLS, RoleListener, TCP, "credentials file is the allow list"},
|
||||
{"scram listener with a password", listener(config.AuthOptions{Username: "edge-01"}), f.serverTLS, RoleListener, TCP, "only to dialers"},
|
||||
{"scram certificate binding without client_auth", listener(config.AuthOptions{Identity: "cn"}), f.serverTLS, RoleListener, TCP, "requires tls.client_auth"},
|
||||
{"token lifetime on tcp", listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, RoleListener, TCP, "only to HTTP listeners"},
|
||||
{"token lifetime too short", listener(config.AuthOptions{TokenLifetimeMS: 900}), f.serverTLS, RoleListener, HTTP, "outside"},
|
||||
{"token lifetime too long", listener(config.AuthOptions{TokenLifetimeMS: 1e13}), f.serverTLS, RoleListener, HTTP, "outside"},
|
||||
{"scram listener without a certificate", listener(config.AuthOptions{}), &tls.Config{}, RoleListener, TCP, "listener certificate"},
|
||||
{"scram dialer without a password", scram(config.AuthOptions{Username: "edge-01"}), &tls.Config{}, RoleDialer, TCP, "requires username and password_file"},
|
||||
{"scram dialer with credentials", dialer(config.AuthOptions{CredentialsFile: f.creds}), &tls.Config{}, RoleDialer, TCP, "only to listeners"},
|
||||
{"scram dialer skips verify", dialer(config.AuthOptions{}), &tls.Config{InsecureSkipVerify: true}, RoleDialer, TCP, "insecure_skip_verify"},
|
||||
{"trusted proxies without a type", &config.AuthOptions{TrustedProxies: proxies}, nil, RoleListener, HTTP, "trusted_proxies is set"},
|
||||
{"trusted proxies under mtls", &config.AuthOptions{Type: MethodMTLS, TrustedProxies: proxies}, mtlsListenerTLS(), RoleListener, HTTP, "trusted_proxies applies only"},
|
||||
{"trusted proxies on a chain source", listener(config.AuthOptions{TrustedProxies: proxies}), nil, RoleChainListener, HTTP, "only to the http sink"},
|
||||
{"trusted proxies on tcp", listener(config.AuthOptions{TrustedProxies: proxies}), nil, RoleListener, TCP, "only to the http sink"},
|
||||
{"trusted proxies with certificate binding", listener(config.AuthOptions{TrustedProxies: proxies, Identity: "cn"}), f.serverTLS, RoleListener, HTTP, "TLS-terminating proxy"},
|
||||
{"malformed trusted proxy", listener(config.AuthOptions{TrustedProxies: []string{"localhost"}}), nil, RoleListener, HTTP, "neither an address"},
|
||||
{"trusted proxies on a dialer", dialer(config.AuthOptions{TrustedProxies: proxies}), &tls.Config{}, RoleDialer, HTTP, "only to listeners"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := New(tc.auth, tc.tls, tc.role); err == nil {
|
||||
t.Fatal("expected an error, got nil")
|
||||
if _, err := New(tc.auth, tc.tls, tc.role, tc.transport); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error %v, want one naming %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// A dialer needs TLS but not client_auth: it pins the server's identity
|
||||
if _, err := New(&config.AuthOptions{Type: MethodMTLS}, &config.TLSOptions{Enabled: true}, RoleDialer); err != nil {
|
||||
t.Fatalf("dialer policy rejected: %v", err)
|
||||
// The rows' bases are valid: a dialer needs TLS but not client_auth, as it
|
||||
// pins the server's identity
|
||||
for _, o := range []*config.AuthOptions{{Type: MethodMTLS}, dialer(config.AuthOptions{})} {
|
||||
if _, err := New(o, &tls.Config{}, RoleDialer, TCP); err != nil {
|
||||
t.Fatalf("dialer policy %s rejected: %v", o.Type, err)
|
||||
}
|
||||
}
|
||||
if _, err := New(listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, RoleListener, HTTP); err != nil {
|
||||
t.Fatalf("scram listener rejected: %v", err)
|
||||
}
|
||||
// Behind proxies TLS ends at the proxy, so the hop may be plaintext
|
||||
if _, err := New(listener(config.AuthOptions{TrustedProxies: proxies}), nil, RoleListener, HTTP); err != nil {
|
||||
t.Fatalf("proxy-mode listener rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +187,7 @@ func TestAuthorizeMatching(t *testing.T) {
|
||||
Type: MethodMTLS,
|
||||
Allow: []string{"edge-01", " edge-02 "},
|
||||
AllowPatterns: []string{`^relay-\d{2}$`},
|
||||
}, mtlsListenerTLS(), RoleListener)
|
||||
}, mtlsListenerTLS(), RoleListener, TCP)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
@@ -185,7 +229,7 @@ func TestAuthorizeMatching(t *testing.T) {
|
||||
// Empty allow and allow_patterns admits any CA-vouched identity, but still
|
||||
// records it and still refuses a certificate with no usable identity field
|
||||
func TestAuthorizeUnrestricted(t *testing.T) {
|
||||
p, err := New(&config.AuthOptions{Type: MethodMTLS}, mtlsListenerTLS(), RoleListener)
|
||||
p, err := New(&config.AuthOptions{Type: MethodMTLS}, mtlsListenerTLS(), RoleListener, TCP)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
@@ -209,7 +253,7 @@ func TestAuthorizeUnrestricted(t *testing.T) {
|
||||
|
||||
func TestResolveNodeBindings(t *testing.T) {
|
||||
newChain := func(binding string) *Policy {
|
||||
p, err := New(&config.AuthOptions{Type: MethodMTLS, NodeBinding: binding}, mtlsListenerTLS(), RoleChainListener)
|
||||
p, err := New(&config.AuthOptions{Type: MethodMTLS, NodeBinding: binding}, mtlsListenerTLS(), RoleChainListener, TCP)
|
||||
if err != nil {
|
||||
t.Fatalf("New(%q): %v", binding, err)
|
||||
}
|
||||
@@ -285,7 +329,7 @@ func TestIdentityApply(t *testing.T) {
|
||||
|
||||
func TestVerifyConnectionPinsServer(t *testing.T) {
|
||||
p, err := New(&config.AuthOptions{Type: MethodMTLS, Allow: []string{"relay.internal"}},
|
||||
&config.TLSOptions{Enabled: true}, RoleDialer)
|
||||
&tls.Config{}, RoleDialer, TCP)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
// SessionCookie carries a browser's token in proxy mode
|
||||
const SessionCookie = "logwisp_session"
|
||||
|
||||
const maxRevoked = 65536 // logged-out tokens remembered until they expire
|
||||
|
||||
// proxyMode is an http sink behind TLS-terminating reverse proxies: only they
|
||||
// may connect, X-Forwarded-For names the client, logins are unbound (logwisp
|
||||
// holds no certificate the browser sees) and sessions may be cookies.
|
||||
type proxyMode struct {
|
||||
trusted []netip.Prefix
|
||||
plaintext bool // no tls on the hop from the proxies
|
||||
mu sync.Mutex
|
||||
revoked map[[32]byte]time.Time
|
||||
}
|
||||
|
||||
func parseProxies(entries []string) (*proxyMode, error) {
|
||||
m := &proxyMode{}
|
||||
for _, e := range entries {
|
||||
e = strings.TrimSpace(e)
|
||||
prefix, err := netip.ParsePrefix(e)
|
||||
if err != nil {
|
||||
addr, aerr := netip.ParseAddr(e)
|
||||
if aerr != nil {
|
||||
return nil, fmt.Errorf("auth: trusted_proxies entry %q is neither an address nor a CIDR", e)
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
prefix = netip.PrefixFrom(addr, addr.BitLen())
|
||||
}
|
||||
m.trusted = append(m.trusted, prefix.Masked())
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *proxyMode) trusts(addr netip.Addr) bool {
|
||||
return slices.ContainsFunc(m.trusted, func(p netip.Prefix) bool { return p.Contains(addr) })
|
||||
}
|
||||
|
||||
// parseHop reads a forwarded or peer address, with or without a port
|
||||
func parseHop(s string) (netip.Addr, bool) {
|
||||
if ap, err := netip.ParseAddrPort(s); err == nil {
|
||||
return ap.Addr().Unmap(), true
|
||||
}
|
||||
addr, err := netip.ParseAddr(strings.TrimSuffix(strings.TrimPrefix(s, "["), "]"))
|
||||
return addr.Unmap(), err == nil
|
||||
}
|
||||
|
||||
// exposedHop reports a plaintext hop from a proxy that may be on another host
|
||||
func (m *proxyMode) exposedHop() bool {
|
||||
return m.plaintext && slices.ContainsFunc(m.trusted, func(p netip.Prefix) bool { return !p.Addr().IsLoopback() })
|
||||
}
|
||||
|
||||
// client is the rightmost X-Forwarded-For hop that is not a trusted proxy:
|
||||
// hops to its left come from the client and prove nothing. When every hop is
|
||||
// trusted, the client sits inside a proxy range and the leftmost hop is it.
|
||||
func (m *proxyMode) client(peer string, h http.Header) (string, error) {
|
||||
if addr, ok := parseHop(peer); !ok || !m.trusts(addr) {
|
||||
return "", fmt.Errorf("%s is not a trusted proxy", peer)
|
||||
}
|
||||
protos := headerList(h, "X-Forwarded-Proto")
|
||||
if len(protos) == 0 || slices.ContainsFunc(protos, func(v string) bool { return !strings.EqualFold(v, "https") }) {
|
||||
return "", fmt.Errorf("proxy forwarded X-Forwarded-Proto %q; sessions need https", strings.Join(protos, ","))
|
||||
}
|
||||
hops := headerList(h, "X-Forwarded-For")
|
||||
if len(hops) == 0 {
|
||||
return "", errors.New("proxy sent no client address in X-Forwarded-For")
|
||||
}
|
||||
for i := len(hops) - 1; ; i-- {
|
||||
addr, ok := parseHop(hops[i])
|
||||
if !ok {
|
||||
return "", fmt.Errorf("malformed X-Forwarded-For hop %q", hops[i])
|
||||
}
|
||||
if i == 0 || !m.trusts(addr) {
|
||||
return addr.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func headerList(h http.Header, key string) []string {
|
||||
var out []string
|
||||
for _, v := range h.Values(key) {
|
||||
for e := range strings.SplitSeq(v, ",") {
|
||||
if e = strings.TrimSpace(e); e != "" {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *proxyMode) revoke(token string, until time.Time) bool {
|
||||
key := sha256.Sum256([]byte(token))
|
||||
now := time.Now()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.revoked == nil {
|
||||
m.revoked = make(map[[32]byte]time.Time)
|
||||
}
|
||||
if len(m.revoked) >= maxRevoked {
|
||||
for k, expiry := range m.revoked {
|
||||
if now.After(expiry) {
|
||||
delete(m.revoked, k)
|
||||
}
|
||||
}
|
||||
if len(m.revoked) >= maxRevoked {
|
||||
return false
|
||||
}
|
||||
}
|
||||
m.revoked[key] = until
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *proxyMode) isRevoked(token string) bool {
|
||||
key := sha256.Sum256([]byte(token))
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
_, ok := m.revoked[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
// BehindProxy reports proxy mode (auth.trusted_proxies on an http sink)
|
||||
func (p *Policy) BehindProxy() bool {
|
||||
return p != nil && p.listener != nil && p.listener.proxy != nil
|
||||
}
|
||||
|
||||
// ClientAddr is the address throttling, sessions and logs name: the socket
|
||||
// peer, or in proxy mode the forwarded client. A proxy-mode request from an
|
||||
// untrusted peer, or one the proxy did not receive over https, is refused.
|
||||
func (p *Policy) ClientAddr(r *http.Request) (string, error) {
|
||||
peer := remoteIP(r.RemoteAddr)
|
||||
if !p.BehindProxy() {
|
||||
return peer, nil
|
||||
}
|
||||
addr, err := p.listener.proxy.client(peer, r.Header)
|
||||
if err != nil {
|
||||
p.rejected.Add(1)
|
||||
return "", fmt.Errorf("%w: %w", ErrRefused, err)
|
||||
}
|
||||
return addr, nil
|
||||
}
|
||||
|
||||
// presentedToken is the bearer token or, in proxy mode, the session cookie.
|
||||
// There another scheme, such as the site's own Basic auth, leaves the cookie
|
||||
// in charge.
|
||||
func (p *Policy) presentedToken(r *http.Request) (string, error) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if scheme, _, _ := strings.Cut(h, " "); p.BehindProxy() && !strings.EqualFold(scheme, "Bearer") {
|
||||
h = ""
|
||||
}
|
||||
if h != "" {
|
||||
token, err := auth.ParseBearerToken(h)
|
||||
if err != nil {
|
||||
return "", errors.New("malformed bearer token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
if p.BehindProxy() {
|
||||
if c, err := r.Cookie(SessionCookie); err == nil && c.Value != "" {
|
||||
return c.Value, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("no bearer token")
|
||||
}
|
||||
|
||||
// sessionCookie sets no Path: it defaults to the directory of /auth, which is
|
||||
// the mount point behind any proxy prefix, so stream and status receive it.
|
||||
func sessionCookie(token string, maxAge int) *http.Cookie {
|
||||
return &http.Cookie{Name: SessionCookie, Value: token, MaxAge: maxAge,
|
||||
HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode}
|
||||
}
|
||||
|
||||
// logout clears the cookie and revokes a valid presented token until it
|
||||
// expires. It answers on /auth itself: a clearing cookie set from another path
|
||||
// would default to another Path and miss the login cookie.
|
||||
func (p *Policy) logout(w http.ResponseWriter, r *http.Request) error {
|
||||
l := p.listener
|
||||
http.SetCookie(w, sessionCookie("", -1))
|
||||
token, err := p.presentedToken(r)
|
||||
if err == nil {
|
||||
_, _, err = l.tokens.ValidateToken(token)
|
||||
}
|
||||
if err == nil && !l.proxy.revoke(token, time.Now().Add(l.lifetime)) {
|
||||
l.busy.Add(1)
|
||||
writeJSON(w, http.StatusServiceUnavailable, authStep{Error: "busy"})
|
||||
return errors.New("auth: logout revocation list is full; the token stays valid until it expires")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"logwisp/internal/chain"
|
||||
"logwisp/internal/config"
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
// forwarding is the proxy's half of every request: the client and https
|
||||
type forwarding struct{ next http.RoundTripper }
|
||||
|
||||
func (f forwarding) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
r = r.Clone(r.Context())
|
||||
r.Header.Set("X-Forwarded-For", "203.0.113.7")
|
||||
r.Header.Set("X-Forwarded-Proto", "https")
|
||||
return f.next.RoundTrip(r)
|
||||
}
|
||||
|
||||
// proxyListener is a plaintext http sink policy behind a proxy on loopback
|
||||
func (f *fixture) proxyListener(t *testing.T) (*Policy, *httptest.Server) {
|
||||
t.Helper()
|
||||
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1"}}, nil, RoleListener, HTTP)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) { l.ServeAuth(w, r) })
|
||||
mux.HandleFunc("GET /protected", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, status, err := l.AuthorizeRequest(r); err != nil {
|
||||
Refuse(w, status)
|
||||
}
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
return l, srv
|
||||
}
|
||||
|
||||
// Behind trusted proxies only they may connect, only requests they received
|
||||
// over https pass, and the client is the rightmost forwarded hop that is not
|
||||
// a proxy (hops to its left are the client's own claims), or the leftmost
|
||||
// when every hop is inside a proxy range.
|
||||
func TestProxyModeTrustsOnlyItsProxies(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1", "10.0.0.0/8"}}, nil, RoleListener, HTTP)
|
||||
for _, tc := range []struct{ name, peer, xff, proto, want string }{
|
||||
{"untrusted peer", "192.0.2.1:5000", "203.0.113.7", "https", ""},
|
||||
{"no proto", "127.0.0.1:5000", "203.0.113.7", "", ""},
|
||||
{"plaintext site", "127.0.0.1:5000", "203.0.113.7", "http", ""},
|
||||
{"one plaintext hop", "127.0.0.1:5000", "203.0.113.7", "https, http", ""},
|
||||
{"no client", "127.0.0.1:5000", "", "https", ""},
|
||||
{"malformed client", "127.0.0.1:5000", "not-an-address", "https", ""},
|
||||
{"spoofed hop", "127.0.0.1:5000", "198.51.100.1, 203.0.113.7", "https", "203.0.113.7"},
|
||||
{"proxy chain", "127.0.0.1:5000", "203.0.113.7, 10.1.2.3", "https", "203.0.113.7"},
|
||||
{"proxy hop with a port", "127.0.0.1:5000", "203.0.113.7, 10.1.2.3:443", "https", "203.0.113.7"},
|
||||
{"bracketed IPv6 client", "127.0.0.1:5000", "[2001:db8::1]", "https", "2001:db8::1"},
|
||||
{"client inside a proxy range", "127.0.0.1:5000", "10.9.9.9, 10.1.2.3", "https", "10.9.9.9"},
|
||||
} {
|
||||
r := httptest.NewRequest(http.MethodGet, "/status", nil)
|
||||
r.RemoteAddr = tc.peer
|
||||
if tc.xff != "" {
|
||||
r.Header.Set("X-Forwarded-For", tc.xff)
|
||||
}
|
||||
if tc.proto != "" {
|
||||
r.Header.Set("X-Forwarded-Proto", tc.proto)
|
||||
}
|
||||
got, err := l.ClientAddr(r)
|
||||
switch {
|
||||
case tc.want == "" && !errors.Is(err, ErrRefused):
|
||||
t.Errorf("%s: client %q, %v; want a refusal", tc.name, got, err)
|
||||
case tc.want != "" && (err != nil || got != tc.want):
|
||||
t.Errorf("%s: client %q, %v; want %s", tc.name, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A browser logs in unbound and gets its session only as a cookie that
|
||||
// scopes itself to the mount; the cookie opens the endpoints, beside the
|
||||
// site's own Basic auth too, until logout revokes it. Outside proxy mode
|
||||
// cookie sessions and logout are refused.
|
||||
func TestBrowserSessionBehindProxy(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l, srv := f.proxyListener(t)
|
||||
client := &http.Client{Transport: forwarding{http.DefaultTransport}}
|
||||
post := func(url string, body any, cookie *http.Cookie) (*http.Response, authStep) {
|
||||
data, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest(http.MethodPost, url+chain.AuthPath, bytes.NewReader(data))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var step authStep
|
||||
json.NewDecoder(resp.Body).Decode(&step)
|
||||
return resp, step
|
||||
}
|
||||
login := func(url string) (*http.Response, authStep) {
|
||||
c := auth.NewScramClient("edge-01", "edge-01-secret", auth.WithMinArgonCost(1, 64))
|
||||
first, _ := c.StartAuthentication()
|
||||
scram, _ := json.Marshal(first)
|
||||
_, step := post(url, chain.Hello{LogWisp: chain.ProtocolVersion, Scram: scram}, nil)
|
||||
if step.Challenge == nil {
|
||||
t.Fatalf("no challenge: %q", step.Error)
|
||||
}
|
||||
proof, err := c.ProcessServerFirstMessage(*step.Challenge)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return post(url, authStep{Proof: &proof, Session: "cookie"}, nil)
|
||||
}
|
||||
open := func(cookie *http.Cookie, basic bool) int {
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/protected", nil)
|
||||
req.AddCookie(cookie)
|
||||
if basic {
|
||||
req.SetBasicAuth("site-user", "site-password")
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
resp, step := login(srv.URL)
|
||||
cookies := resp.Cookies()
|
||||
if resp.StatusCode != http.StatusOK || step.Token != "" || len(cookies) != 1 {
|
||||
t.Fatalf("login: %d, body token %q, %d cookies", resp.StatusCode, step.Token, len(cookies))
|
||||
}
|
||||
c := cookies[0]
|
||||
if c.Name != SessionCookie || !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode ||
|
||||
c.Path != "" || c.MaxAge != int(DefaultTokenLifetime.Seconds()) {
|
||||
t.Fatalf("session cookie %+v", c)
|
||||
}
|
||||
for _, basic := range []bool{false, true} {
|
||||
if got := open(c, basic); got != http.StatusOK {
|
||||
t.Fatalf("with the cookie (Basic auth %v): %d", basic, got)
|
||||
}
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+chain.AuthPath, strings.NewReader(`{"logout":true}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.SetBasicAuth("site-user", "site-password")
|
||||
req.AddCookie(c)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if cleared := resp.Cookies(); resp.StatusCode != http.StatusNoContent || len(cleared) != 1 || cleared[0].MaxAge >= 0 {
|
||||
t.Fatalf("logout: %d, cookies %+v", resp.StatusCode, cleared)
|
||||
}
|
||||
if got := open(c, false); got != http.StatusUnauthorized {
|
||||
t.Fatalf("after logout: %d, want 401", got)
|
||||
}
|
||||
if l.allowed.Load() != 1 {
|
||||
t.Fatalf("logins = %d", l.allowed.Load())
|
||||
}
|
||||
|
||||
direct := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
|
||||
ds, _ := f.httpListener(t, direct, f.serverTLS)
|
||||
client = f.httpClient(f.dialer(t, "edge-01", "edge-01-secret"))
|
||||
proof := &auth.ClientFinalRequest{FullNonce: strings.Repeat("n", 32), ClientProof: "AAAA"}
|
||||
for _, step := range []authStep{{Proof: proof, Session: "cookie"}, {Logout: true}} {
|
||||
if resp, _ := post(ds.URL, step, nil); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("%+v outside proxy mode: %d, want 400", step, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Behind a proxy logwisp never sees the certificate a client binds to, so
|
||||
// only unbound proofs (logwisp auth token -unbound) log in.
|
||||
func TestOnlyUnboundLoginsBehindProxy(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{TrustedProxies: []string{"127.0.0.1"}}, f.serverTLS, RoleListener, HTTP)
|
||||
srv, _ := f.httpListener(t, l, f.serverTLS)
|
||||
for _, unbound := range []bool{false, true} {
|
||||
d := f.dialer(t, "edge-01", "edge-01-secret")
|
||||
if unbound {
|
||||
d.Unbind()
|
||||
}
|
||||
client := f.httpClient(d)
|
||||
client.Transport = forwarding{client.Transport}
|
||||
_, err := d.Token(t.Context(), client, srv.URL)
|
||||
if unbound != (err == nil) {
|
||||
t.Errorf("unbound %v: %v", unbound, err)
|
||||
}
|
||||
}
|
||||
if l.allowed.Load() != 1 || l.Rejected() != 1 {
|
||||
t.Fatalf("logins %d, refusals %d; want 1 and 1", l.allowed.Load(), l.Rejected())
|
||||
}
|
||||
}
|
||||
|
||||
// Behind a proxy the limiter counts the forwarded client, and an IPv6 client
|
||||
// by its /64, so neither one proxy address nor one host's many addresses
|
||||
// share or escape a budget.
|
||||
func TestProxyModeThrottlesTheForwardedClient(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
_, srv := f.proxyListener(t)
|
||||
hello := func(client string) int {
|
||||
first, _ := auth.NewScramClient("edge-01", "edge-01-secret").StartAuthentication()
|
||||
scram, _ := json.Marshal(first)
|
||||
body, _ := json.Marshal(chain.Hello{LogWisp: chain.ProtocolVersion, Scram: scram})
|
||||
req, _ := http.NewRequest(http.MethodPost, srv.URL+chain.AuthPath, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Forwarded-For", client)
|
||||
req.Header.Set("X-Forwarded-Proto", "https")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
clients []string
|
||||
next, another string
|
||||
}{
|
||||
{"one client", []string{"203.0.113.7"}, "203.0.113.7", "198.51.100.1"},
|
||||
{"one IPv6 /64", []string{"2001:db8::1", "2001:db8::2", "2001:db8::3", "2001:db8::4"}, "2001:db8::5", "2001:db8:0:1::1"},
|
||||
} {
|
||||
for i := range limitPending {
|
||||
hello(tc.clients[i%len(tc.clients)]) // unanswered: each holds a slot
|
||||
}
|
||||
if got := hello(tc.next); got != http.StatusTooManyRequests {
|
||||
t.Errorf("%s past its pending cap: %d, want 429", tc.name, got)
|
||||
}
|
||||
if got := hello(tc.another); got != http.StatusOK {
|
||||
t.Errorf("%s: another client %s: %d, want 200", tc.name, tc.another, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// /auth takes only JSON: a cross-origin page cannot send it without a
|
||||
// preflight nobody answers, so it cannot drive a login or logout.
|
||||
func TestAuthTakesOnlyJSON(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
_, srv := f.proxyListener(t)
|
||||
client := &http.Client{Transport: forwarding{http.DefaultTransport}}
|
||||
resp, err := client.Post(srv.URL+chain.AuthPath, "text/plain", strings.NewReader(`{"logout":true}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("text/plain /auth: %d, want 415", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,996 @@
|
||||
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"
|
||||
|
||||
"logwisp/internal/chain"
|
||||
"logwisp/internal/config"
|
||||
"logwisp/internal/tlsx"
|
||||
"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 `logwisp 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 `logwisp 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 (logwisp 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
|
||||
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")
|
||||
}
|
||||
d.renewAt.Store(renewAt(time.Now(), time.Duration(step.ExpiresIn)*time.Second).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
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
return step, resp, fmt.Errorf("%w: server: %s", ErrRefused, msg)
|
||||
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
|
||||
func throttleKey(ip string) string {
|
||||
addr, err := netip.ParseAddr(ip)
|
||||
if err != nil || addr.Is4() {
|
||||
return ip
|
||||
}
|
||||
return netip.PrefixFrom(addr, 64).Masked().String()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"logwisp/internal/chain"
|
||||
"logwisp/internal/config"
|
||||
"logwisp/internal/testutil"
|
||||
|
||||
"github.com/lixenwraith/auth"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
minArgonTime, minArgonMemory = 1, 64 // test verifiers use a cheap profile
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
pki *testutil.PKI
|
||||
dir string
|
||||
creds string // credentials file holding edge-01 and edge-02
|
||||
serverTLS *tls.Config
|
||||
clientTLS *tls.Config
|
||||
}
|
||||
|
||||
func cheapCredential(t *testing.T, user, password string) *auth.Credential {
|
||||
t.Helper()
|
||||
c, err := auth.NewCredential(user, password, auth.WithTime(1), auth.WithMemory(64), auth.WithThreads(1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
f := &fixture{pki: testutil.NewPKI(t, "edge-01"), dir: t.TempDir()}
|
||||
data, err := (&Credentials{
|
||||
DecoyKey: bytes.Repeat([]byte{1}, 32),
|
||||
Users: []*auth.Credential{cheapCredential(t, "edge-01", "edge-01-secret"), cheapCredential(t, "edge-02", "edge-02-secret")},
|
||||
}).Marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.creds = f.write(t, "users.toml", string(data))
|
||||
f.serverTLS = &tls.Config{Certificates: []tls.Certificate{f.keyPair(t, f.pki.ServerCert, f.pki.ServerKey)}, ClientCAs: f.pool(t)}
|
||||
f.clientTLS = &tls.Config{RootCAs: f.pool(t), ServerName: "127.0.0.1",
|
||||
Certificates: []tls.Certificate{f.keyPair(t, f.pki.ClientCert, f.pki.ClientKey)}}
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fixture) write(t *testing.T, name, contents string) string {
|
||||
path := filepath.Join(f.dir, name)
|
||||
testutil.WriteFile(t, path, contents)
|
||||
return path
|
||||
}
|
||||
|
||||
func (f *fixture) keyPair(t *testing.T, cert, key string) tls.Certificate {
|
||||
kp, err := tls.LoadX509KeyPair(cert, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return kp
|
||||
}
|
||||
|
||||
// serverLeaf is another server certificate from the same CA
|
||||
func (f *fixture) serverLeaf(t *testing.T, name string) tls.Certificate {
|
||||
cert, key := f.pki.Leaf(t, name, name+".internal", false)
|
||||
return f.keyPair(t, cert, key)
|
||||
}
|
||||
|
||||
func (f *fixture) pool(t *testing.T) *x509.CertPool {
|
||||
pem, err := os.ReadFile(f.pki.CA)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AppendCertsFromPEM(pem)
|
||||
return pool
|
||||
}
|
||||
|
||||
func (f *fixture) listener(t *testing.T, o config.AuthOptions, tlsCfg *tls.Config, role Role, transport Transport) *Policy {
|
||||
t.Helper()
|
||||
o.Type, o.CredentialsFile = MethodSCRAM, f.creds
|
||||
p, err := New(&o, tlsCfg, role, transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(p.Close)
|
||||
return p
|
||||
}
|
||||
|
||||
func (f *fixture) dialer(t *testing.T, user, password string) *Policy {
|
||||
t.Helper()
|
||||
pw := f.write(t, user+"-"+strings.ReplaceAll(password, "/", "_")+".pass", password+"\n")
|
||||
p, err := New(&config.AuthOptions{Type: MethodSCRAM, Username: user, PasswordFile: pw}, f.clientTLS, RoleDialer, TCP)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
type admitted struct {
|
||||
adm *Admission
|
||||
err error
|
||||
}
|
||||
|
||||
// connect runs a TLS pair over net.Pipe: the server side admits with
|
||||
// listener and hands the admission to decide (default Accept); the client
|
||||
// side greets.
|
||||
func (f *fixture) connect(t *testing.T, listener *Policy, serverTLS *tls.Config, dialer *Policy, decide func(*Admission)) (admitted, *bufio.Reader, error) {
|
||||
t.Helper()
|
||||
if decide == nil {
|
||||
decide = func(a *Admission) { a.Accept() }
|
||||
}
|
||||
sc, cc := net.Pipe()
|
||||
server, client := tls.Server(sc, serverTLS), tls.Client(cc, f.clientTLS)
|
||||
// Raw ends: a tls.Conn would wait out close_notify on a pipe nobody reads
|
||||
t.Cleanup(func() { sc.Close(); cc.Close() })
|
||||
done := make(chan admitted, 1)
|
||||
go func() {
|
||||
if err := server.Handshake(); err != nil {
|
||||
done <- admitted{err: err}
|
||||
return
|
||||
}
|
||||
cs := server.ConnectionState()
|
||||
adm, err := listener.Admit(server, &cs, true, 5*time.Second)
|
||||
if err == nil && decide != nil {
|
||||
decide(adm)
|
||||
}
|
||||
done <- admitted{adm, err}
|
||||
}()
|
||||
if err := client.Handshake(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := dialer.Greet(t.Context(), client, "declared-node")
|
||||
if err != nil || dialer == nil || dialer.dialer == nil {
|
||||
cc.Close() // a dialer that does not read must not block the server's answer
|
||||
}
|
||||
return <-done, r, err
|
||||
}
|
||||
|
||||
// A full TCP exchange admits the user, and bytes arriving in the same read
|
||||
// as the final message reach the dialer through the reader Greet returns.
|
||||
func TestTCPExchangeAdmitsAndKeepsTheStream(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleChainListener, TCP)
|
||||
res, r, err := f.connect(t, l, f.serverTLS, f.dialer(t, "edge-01", "edge-01-secret"), func(a *Admission) {
|
||||
a.final = append(a.final, "first entry\n"...) // as TCP would coalesce them
|
||||
if err := a.Accept(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err != nil || res.err != nil {
|
||||
t.Fatalf("exchange: dialer %v, listener %v", err, res.err)
|
||||
}
|
||||
if res.adm.Identity != (Identity{Name: "edge-01", Method: MethodSCRAM}) || res.adm.Hello.Node != "declared-node" {
|
||||
t.Fatalf("admission = %+v / %+v", res.adm.Identity, res.adm.Hello)
|
||||
}
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil || line != "first entry\n" {
|
||||
t.Fatalf("stream after the exchange = %q, %v", line, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A wrong password and an unknown user fail identically on the wire, so a
|
||||
// probe cannot tell which accounts exist.
|
||||
func TestTCPExchangeRefusesWrongPasswordAndUnknownUserAlike(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
|
||||
var seen []string
|
||||
for _, d := range []*Policy{f.dialer(t, "edge-01", "not-the-secret"), f.dialer(t, "edge-99", "edge-01-secret")} {
|
||||
res, _, err := f.connect(t, l, f.serverTLS, d, nil)
|
||||
if !errors.Is(err, ErrRefused) || !errors.Is(res.err, ErrRefused) {
|
||||
t.Fatalf("dialer %v, listener %v; want both refused", err, res.err)
|
||||
}
|
||||
seen = append(seen, err.Error())
|
||||
}
|
||||
if seen[0] != seen[1] {
|
||||
t.Fatalf("wrong password and unknown user differ: %q vs %q", seen[0], seen[1])
|
||||
}
|
||||
}
|
||||
|
||||
// A proof bound to a certificate other than the listener's own fails, even
|
||||
// though that certificate chains to the trusted CA, and the listener counts
|
||||
// the mismatch so interception is not mistaken for a wrong password.
|
||||
func TestTCPExchangeRefusesAnotherCertificate(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
other := f.serverTLS.Clone()
|
||||
other.Certificates = []tls.Certificate{f.serverLeaf(t, "relay-b")}
|
||||
l := f.listener(t, config.AuthOptions{}, other, RoleListener, TCP) // binds to relay-b
|
||||
res, _, err := f.connect(t, l, f.serverTLS, f.dialer(t, "edge-01", "edge-01-secret"), nil)
|
||||
if !errors.Is(err, ErrRefused) || !errors.Is(res.err, ErrRefused) {
|
||||
t.Fatalf("dialer %v, listener %v; want both refused", err, res.err)
|
||||
}
|
||||
if n := l.listener.bindingMismatch.Load(); n != 1 {
|
||||
t.Fatalf("binding mismatches = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A scram listener refuses a hello without credentials, and a listener
|
||||
// without scram answers credentials instead of leaving the dialer waiting.
|
||||
func TestHelloAndPolicyMustAgree(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleChainListener, TCP)
|
||||
if res, _, _ := f.connect(t, l, f.serverTLS, nil, nil); !errors.Is(res.err, ErrRefused) {
|
||||
t.Fatalf("scram listener admitted a plain hello: %v", res.err)
|
||||
}
|
||||
if _, _, err := f.connect(t, nil, f.serverTLS, f.dialer(t, "edge-01", "edge-01-secret"), nil); !errors.Is(err, ErrRefused) ||
|
||||
!strings.Contains(err.Error(), "not enabled") {
|
||||
t.Fatalf("dialer against a plain listener: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The dialer's exchange ends when its context does, so a silent server never
|
||||
// holds a sink's Stop.
|
||||
func TestGreetEndsWithContext(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
sc, cc := net.Pipe()
|
||||
server, client := tls.Server(sc, f.serverTLS), tls.Client(cc, f.clientTLS)
|
||||
t.Cleanup(func() { sc.Close(); cc.Close() })
|
||||
go func() {
|
||||
server.Handshake()
|
||||
bufio.NewReader(server).ReadString('\n') // swallow the hello, never answer
|
||||
}()
|
||||
if err := client.Handshake(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
if _, err := f.dialer(t, "edge-01", "edge-01-secret").Greet(ctx, client, ""); err == nil {
|
||||
t.Fatal("Greet succeeded against a silent server")
|
||||
}
|
||||
if d := time.Since(start); d > 2*time.Second {
|
||||
t.Fatalf("Greet took %v after its context ended", d)
|
||||
}
|
||||
}
|
||||
|
||||
// An exchange abandoned after its challenge leaves the auth handshake table
|
||||
// and the limiter at once instead of holding a slot until it times out.
|
||||
func TestAbandonedExchangeReleasesItsSlot(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
|
||||
sc, cc := net.Pipe()
|
||||
server, client := tls.Server(sc, f.serverTLS), tls.Client(cc, f.clientTLS)
|
||||
t.Cleanup(func() { sc.Close(); cc.Close() })
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
server.Handshake()
|
||||
cs := server.ConnectionState()
|
||||
_, err := l.Admit(server, &cs, false, 5*time.Second)
|
||||
done <- err
|
||||
}()
|
||||
if err := client.Handshake(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := auth.NewScramClient("edge-01", "edge-01-secret").StartAuthentication()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scram, _ := json.Marshal(first)
|
||||
hello, _ := chain.EncodeHello(chain.Hello{Scram: scram})
|
||||
client.Write(hello)
|
||||
step, err := readStep(bufio.NewReader(client))
|
||||
if err != nil || step.Challenge == nil {
|
||||
t.Fatalf("challenge: %+v, %v", step, err)
|
||||
}
|
||||
cc.Close()
|
||||
if err := <-done; err == nil {
|
||||
t.Fatal("abandoned exchange was admitted")
|
||||
}
|
||||
if _, err := l.listener.server.Load().ProcessClientFinalMessage(step.Challenge.FullNonce, "AAAA"); !errors.Is(err, auth.ErrSCRAMInvalidNonce) {
|
||||
t.Fatalf("abandoned handshake still pending: %v", err)
|
||||
}
|
||||
lim := &l.listener.limit
|
||||
lim.mu.Lock()
|
||||
defer lim.mu.Unlock()
|
||||
if pl := lim.peers["pipe"]; pl == nil || len(pl.pending) != 0 || pl.reserved != 0 {
|
||||
t.Fatalf("limiter still holds the abandoned exchange: %+v", pl)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal after authentication, such as node binding, reaches the dialer as
|
||||
// a reason and never as a final message it could take for admission.
|
||||
func TestRejectAfterExchangeSendsReason(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleChainListener, TCP)
|
||||
_, _, err := f.connect(t, l, f.serverTLS, f.dialer(t, "edge-01", "edge-01-secret"), func(a *Admission) {
|
||||
a.Reject("node label rejected")
|
||||
})
|
||||
if !errors.Is(err, ErrRefused) || !strings.Contains(err.Error(), "node label rejected") {
|
||||
t.Fatalf("dialer after Reject: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// With identity set under scram, the client certificate must name the user:
|
||||
// a peer needs its own certificate and its own password, and a token works
|
||||
// only with the certificate of its login.
|
||||
func TestCertificateBindsToUser(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
mtls := f.serverTLS.Clone()
|
||||
mtls.ClientAuth = tls.RequireAndVerifyClientCert
|
||||
l := f.listener(t, config.AuthOptions{Identity: "cn"}, mtls, RoleListener, TCP)
|
||||
if res, _, err := f.connect(t, l, mtls, f.dialer(t, "edge-01", "edge-01-secret"), nil); err != nil || res.err != nil {
|
||||
t.Fatalf("own certificate and password: dialer %v, listener %v", err, res.err)
|
||||
}
|
||||
if res, _, _ := f.connect(t, l, mtls, f.dialer(t, "edge-02", "edge-02-secret"), nil); !errors.Is(res.err, ErrRefused) {
|
||||
t.Fatalf("edge-01's certificate admitted user edge-02: %v", res.err)
|
||||
}
|
||||
|
||||
h := f.listener(t, config.AuthOptions{Identity: "cn"}, mtls, RoleListener, HTTP)
|
||||
srv, _ := f.httpListener(t, h, mtls)
|
||||
d := f.dialer(t, "edge-01", "edge-01-secret")
|
||||
token, err := d.Token(t.Context(), f.httpClient(d), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := f.clientTLS.Clone()
|
||||
cert, key := f.pki.Leaf(t, "edge-02", "edge-02", true)
|
||||
other.Certificates = []tls.Certificate{f.keyPair(t, cert, key)}
|
||||
client := &http.Client{Transport: &http.Transport{TLSClientConfig: other}}
|
||||
resp := get(t, client, srv.URL+"/protected", func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+token) })
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("edge-01's token with edge-02's certificate: %d, want 403", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown user's challenge salt comes from the file's decoy key, so it is
|
||||
// the same after a restart and a prober learns nothing from restarts.
|
||||
func TestUnknownUserSaltSurvivesRestart(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
var salts []string
|
||||
for range 2 {
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
|
||||
c, err := l.listener.server.Load().ProcessClientFirstMessage("nobody", strings.Repeat("n", 32))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
salts = append(salts, c.Salt)
|
||||
}
|
||||
if salts[0] != salts[1] {
|
||||
t.Fatalf("unknown user salts differ across restarts: %q, %q", salts[0], salts[1])
|
||||
}
|
||||
}
|
||||
|
||||
// Unauthenticated peers get 4 KiB: a longer hello line or /auth body is
|
||||
// refused before it is parsed.
|
||||
func TestPreAuthInputIsCapped(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
|
||||
sc, cc := net.Pipe()
|
||||
server, client := tls.Server(sc, f.serverTLS), tls.Client(cc, f.clientTLS)
|
||||
t.Cleanup(func() { sc.Close(); cc.Close() })
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
server.Handshake()
|
||||
cs := server.ConnectionState()
|
||||
_, err := l.Admit(server, &cs, true, 5*time.Second)
|
||||
done <- err
|
||||
}()
|
||||
if err := client.Handshake(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
go client.Write(bytes.Repeat([]byte("x"), 2*maxAuthLine))
|
||||
if err := <-done; err == nil || !strings.Contains(err.Error(), "exceeds") {
|
||||
t.Fatalf("oversized hello: %v", err)
|
||||
}
|
||||
|
||||
h := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
|
||||
srv, _ := f.httpListener(t, h, f.serverTLS)
|
||||
hc := &http.Client{Transport: &http.Transport{TLSClientConfig: f.clientTLS}}
|
||||
resp, err := hc.Post(srv.URL+chain.AuthPath, "application/json", bytes.NewReader(bytes.Repeat([]byte(" "), 2*maxAuthLine)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("oversized /auth body: %d, want 413", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// A dialer refuses a challenge below its Argon2 floor before computing a
|
||||
// proof, so a hostile server cannot obtain a cheaply guessable one.
|
||||
func TestDialerRefusesACheapChallenge(t *testing.T) {
|
||||
f := newFixture(t) // verifiers at t=1, m=64 KiB
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
|
||||
d := f.dialer(t, "edge-01", "edge-01-secret")
|
||||
minArgonTime, minArgonMemory = auth.DefaultArgonTime, auth.DefaultArgonMemory
|
||||
t.Cleanup(func() { minArgonTime, minArgonMemory = 1, 64 })
|
||||
if _, _, err := f.connect(t, l, f.serverTLS, d, nil); !errors.Is(err, auth.ErrSCRAMParamsTooSmall) {
|
||||
t.Fatalf("cheap challenge: %v", err)
|
||||
}
|
||||
if n := l.allowed.Load(); n != 0 {
|
||||
t.Fatalf("logins = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Only failed attempts drain an address's budget; beyond it the listener
|
||||
// answers "too many attempts" without running the exchange.
|
||||
func TestOnlyFailedAttemptsAreThrottled(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, TCP)
|
||||
right := f.dialer(t, "edge-01", "edge-01-secret")
|
||||
for i := range limitBurst + 1 {
|
||||
if _, _, err := f.connect(t, l, f.serverTLS, right, nil); err != nil {
|
||||
t.Fatalf("successful login %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
wrong := f.dialer(t, "edge-01", "not-the-secret")
|
||||
for range limitBurst {
|
||||
f.connect(t, l, f.serverTLS, wrong, nil)
|
||||
}
|
||||
_, _, err := f.connect(t, l, f.serverTLS, right, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "too many attempts") || l.listener.throttled.Load() != 1 {
|
||||
t.Fatalf("attempt over budget: %v, throttled %d", err, l.listener.throttled.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// Successes are refunded; unfinished exchanges are capped per address from
|
||||
// the moment they start, so concurrent hellos cannot all pass; a full table
|
||||
// refuses new addresses, and the sweep frees addresses whose challenges were
|
||||
// abandoned.
|
||||
func TestLimiterBounds(t *testing.T) {
|
||||
var l limiter
|
||||
for i := range 2 * limitBurst {
|
||||
if !l.start("10.0.0.1") {
|
||||
t.Fatalf("successful attempt %d throttled", i)
|
||||
}
|
||||
l.track("10.0.0.1", "n")
|
||||
l.done("10.0.0.1", "n")
|
||||
l.succeeded("10.0.0.1")
|
||||
}
|
||||
for i := range limitPending {
|
||||
if !l.start("10.0.0.2") {
|
||||
t.Fatalf("pending exchange %d throttled", i)
|
||||
}
|
||||
}
|
||||
if l.start("10.0.0.2") {
|
||||
t.Fatal("an exchange beyond the pending cap was admitted before any challenge")
|
||||
}
|
||||
l.release("10.0.0.2")
|
||||
if !l.start("10.0.0.2") {
|
||||
t.Fatal("a released reservation still counts")
|
||||
}
|
||||
for i := len(l.peers); i < limitPeers; i++ {
|
||||
l.peers[net.IPv4(10, 1, byte(i>>8), byte(i)).String()] = &peerLimit{
|
||||
seen: time.Now(), pending: map[string]time.Time{"abandoned": time.Now().Add(time.Second)},
|
||||
}
|
||||
}
|
||||
if l.start("192.0.2.1") {
|
||||
t.Fatal("a full table admitted a new address")
|
||||
}
|
||||
l.sweep(time.Now().Add(2 * limitIdle))
|
||||
if len(l.peers) != 1 { // 10.0.0.2 still holds its reservations
|
||||
t.Fatalf("%d addresses left after their challenges expired, want 1", len(l.peers))
|
||||
}
|
||||
}
|
||||
|
||||
// httpListener serves /auth and one protected endpoint over TLS
|
||||
func (f *fixture) httpListener(t *testing.T, p *Policy, tlsCfg *tls.Config) (*httptest.Server, *atomic.Int64) {
|
||||
t.Helper()
|
||||
var hits atomic.Int64
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) { p.ServeAuth(w, r) })
|
||||
mux.HandleFunc("GET /protected", func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
if _, status, err := p.AuthorizeRequest(r); err != nil {
|
||||
Refuse(w, status)
|
||||
}
|
||||
})
|
||||
srv := httptest.NewUnstartedServer(mux)
|
||||
srv.TLS = tlsCfg
|
||||
srv.StartTLS()
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &hits
|
||||
}
|
||||
|
||||
func (f *fixture) httpClient(d *Policy) *http.Client {
|
||||
cfg := f.clientTLS.Clone()
|
||||
cfg.VerifyConnection = d.VerifyConnection
|
||||
return &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}}
|
||||
}
|
||||
|
||||
func get(t *testing.T, client *http.Client, url string, prepare func(*http.Request)) *http.Response {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
prepare(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp
|
||||
}
|
||||
|
||||
// A login over HTTP yields a bearer token the endpoint accepts; a garbage
|
||||
// token, or one another listener instance issued, gets 401 and a challenge
|
||||
// header; a refused token can be dropped and replaced by a fresh login.
|
||||
func TestHTTPTokenFlow(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
|
||||
srv, _ := f.httpListener(t, l, f.serverTLS)
|
||||
d := f.dialer(t, "edge-01", "edge-01-secret")
|
||||
client := f.httpClient(d)
|
||||
|
||||
resp := get(t, client, srv.URL+"/protected", func(r *http.Request) {
|
||||
if err := d.Prepare(t.Context(), client, srv.URL, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("with a token: %d", resp.StatusCode)
|
||||
}
|
||||
other := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
|
||||
foreignSrv, _ := f.httpListener(t, other, f.serverTLS)
|
||||
foreign, err := f.dialer(t, "edge-01", "edge-01-secret").Token(t.Context(), f.httpClient(d), foreignSrv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, header := range map[string]string{"garbage": "Bearer not-a-token", "foreign": "Bearer " + foreign, "missing": ""} {
|
||||
resp := get(t, client, srv.URL+"/protected", func(r *http.Request) { r.Header.Set("Authorization", header) })
|
||||
if resp.StatusCode != http.StatusUnauthorized || !strings.HasPrefix(resp.Header.Get("WWW-Authenticate"), "Bearer") {
|
||||
t.Errorf("%s token: %d %q", name, resp.StatusCode, resp.Header.Get("WWW-Authenticate"))
|
||||
}
|
||||
}
|
||||
logins := l.allowed.Load()
|
||||
if !d.Invalidate(http.StatusUnauthorized, nil) || d.dialer.token.Load() != nil {
|
||||
t.Fatal("a refused token was not dropped")
|
||||
}
|
||||
resp = get(t, client, srv.URL+"/protected", func(r *http.Request) { d.Prepare(t.Context(), client, srv.URL, r) })
|
||||
if resp.StatusCode != http.StatusOK || l.allowed.Load() != logins+1 {
|
||||
t.Fatalf("after Invalidate: %d, logins %d -> %d", resp.StatusCode, logins, l.allowed.Load())
|
||||
}
|
||||
}
|
||||
|
||||
// A server that cannot sign the final message with the user's verifier gets
|
||||
// no token stored, whatever token it offers.
|
||||
func TestForgedFinalYieldsNoToken(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) {
|
||||
body := new(bytes.Buffer)
|
||||
body.ReadFrom(r.Body)
|
||||
if bytes.Contains(body.Bytes(), []byte(`"proof"`)) {
|
||||
writeJSON(w, http.StatusOK, authStep{Final: &auth.ServerFinalMessage{ServerSignature: "AAAA"}, Token: "forged"})
|
||||
return
|
||||
}
|
||||
r.Body = io.NopCloser(body)
|
||||
l.ServeAuth(w, r)
|
||||
})
|
||||
srv := httptest.NewUnstartedServer(mux)
|
||||
srv.TLS = f.serverTLS
|
||||
srv.StartTLS()
|
||||
t.Cleanup(srv.Close)
|
||||
d := f.dialer(t, "edge-01", "edge-01-secret")
|
||||
if _, err := d.Token(t.Context(), f.httpClient(d), srv.URL); err == nil {
|
||||
t.Fatal("forged final accepted")
|
||||
}
|
||||
if d.dialer.token.Load() != nil {
|
||||
t.Fatal("token stored from a forged final")
|
||||
}
|
||||
}
|
||||
|
||||
// After the first answer every connection must present the bound certificate:
|
||||
// a second, equally CA-valid server never sees the proof, unbound logins
|
||||
// (through a proxy) included.
|
||||
func TestPinnedCertificateAcrossConnections(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
|
||||
a, _ := f.httpListener(t, l, f.serverTLS)
|
||||
otherTLS := f.serverTLS.Clone()
|
||||
otherTLS.Certificates = []tls.Certificate{f.serverLeaf(t, "relay-b")}
|
||||
b, bHits := f.httpListener(t, l, otherTLS)
|
||||
|
||||
for _, unbound := range []bool{false, true} {
|
||||
d := f.dialer(t, "edge-01", "edge-01-secret")
|
||||
if unbound {
|
||||
d.Unbind()
|
||||
}
|
||||
client := f.httpClient(d)
|
||||
var dials atomic.Int64
|
||||
tr := client.Transport.(*http.Transport)
|
||||
tr.DisableKeepAlives = true
|
||||
tr.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
target := a.Listener.Addr().String()
|
||||
if dials.Add(1) > 1 {
|
||||
target = b.Listener.Addr().String() // every later connection lands on b
|
||||
}
|
||||
return (&net.Dialer{}).DialContext(ctx, network, target)
|
||||
}
|
||||
_, err := d.Token(t.Context(), client, a.URL)
|
||||
if !errors.Is(err, errPinMismatch) {
|
||||
t.Fatalf("unbound %v: login across certificates: %v", unbound, err)
|
||||
}
|
||||
if !d.Invalidate(0, err) {
|
||||
t.Fatalf("unbound %v: a pin mismatch did not drop the login", unbound)
|
||||
}
|
||||
}
|
||||
if bHits.Load() != 0 || l.allowed.Load() != 0 {
|
||||
t.Fatal("the proof reached a server with another certificate")
|
||||
}
|
||||
}
|
||||
|
||||
// A hello refused after it was admitted, such as a malformed username, frees
|
||||
// its pending slot: otherwise four of them would lock the address out.
|
||||
func TestRefusedHellosFreeTheirSlot(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
|
||||
srv, _ := f.httpListener(t, l, f.serverTLS)
|
||||
client := &http.Client{Transport: &http.Transport{TLSClientConfig: f.clientTLS}}
|
||||
hello := func(user string) int {
|
||||
body := fmt.Sprintf(`{"logwisp":1,"scram":{"username":%q,"client_nonce":%q}}`, user, strings.Repeat("n", 32))
|
||||
resp, err := client.Post(srv.URL+chain.AuthPath, "application/json", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
for range limitPending + 1 {
|
||||
if got := hello("edge,01"); got != http.StatusBadRequest {
|
||||
t.Fatalf("malformed username: %d, want 400", got)
|
||||
}
|
||||
}
|
||||
if got := hello("edge-01"); got != http.StatusOK {
|
||||
t.Fatalf("a valid hello after refused ones: %d, want 200", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Password files from editors and from the CLI read the same.
|
||||
func TestReadPasswordTrimsOneLineBreak(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
for _, contents := range []string{"secret", "secret\n", "secret\r\n"} {
|
||||
if pw, err := ReadPassword(f.write(t, "pw", contents)); err != nil || pw != "secret" {
|
||||
t.Errorf("ReadPassword(%q) = %q, %v", contents, pw, err)
|
||||
}
|
||||
}
|
||||
if _, err := ReadPassword(f.write(t, "pw", "\n")); err == nil {
|
||||
t.Error("an empty password was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// A credentials file is validated whole, and Marshal round-trips. Each case
|
||||
// breaks one rule of an otherwise valid file.
|
||||
func TestCredentialsFileValidation(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
data, err := os.ReadFile(f.creds)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := ParseCredentials(data)
|
||||
if err != nil || len(c.Users) != 2 || c.Users[1].Username != "edge-02" {
|
||||
t.Fatalf("round trip: %+v, %v", c, err)
|
||||
}
|
||||
valid := string(data)
|
||||
decoyLine := regexp.MustCompile(`(?m)^decoy_key = .*\n`)
|
||||
stronger := cheapCredential(t, "edge-03", "edge-03-secret")
|
||||
stronger.ArgonTime = 2
|
||||
mixed, _ := (&Credentials{DecoyKey: c.DecoyKey, Users: []*auth.Credential{c.Users[0], stronger}}).Marshal()
|
||||
for name, tc := range map[string]struct{ contents, want string }{
|
||||
"no decoy key": {decoyLine.ReplaceAllString(valid, ""), "decoy_key must hold"},
|
||||
"short decoy": {decoyLine.ReplaceAllString(valid, "decoy_key = \"AAAA\"\n"), "decoy_key must hold"},
|
||||
"no users": {decoyLine.FindString(valid), "no users"},
|
||||
"duplicate": {strings.Replace(valid, `"edge-02"`, `"edge-01"`, 1), "duplicate user"},
|
||||
"unknown user key": {strings.Replace(valid, "argon_time", "comment = 1\nargon_time", 1), `unknown key "comment"`},
|
||||
"unknown file key": {"comment = 1\n" + valid, `unknown key "comment"`},
|
||||
"mixed profile": {string(mixed), "profile differs"},
|
||||
} {
|
||||
if _, err := ParseCredentials([]byte(tc.contents)); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("%s: error %v, want %q", name, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A scram listener's Authorize refuses, so a call site that skipped the
|
||||
// exchange admits nobody rather than everybody.
|
||||
func TestScramListenerAuthorizeFailsClosed(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{}, f.serverTLS, RoleListener, HTTP)
|
||||
if _, err := l.Authorize(&tls.ConnectionState{}); !errors.Is(err, ErrRefused) {
|
||||
t.Fatalf("Authorize under scram = %v, want a refusal", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A token is renewed ahead of its expiry, so a healthy link never pays a
|
||||
// refused request each lifetime.
|
||||
func TestTokenIsRenewedBeforeExpiry(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
l := f.listener(t, config.AuthOptions{TokenLifetimeMS: MinTokenLifetime.Milliseconds()}, f.serverTLS, RoleListener, HTTP)
|
||||
srv, _ := f.httpListener(t, l, f.serverTLS)
|
||||
d := f.dialer(t, "edge-01", "edge-01-secret")
|
||||
client := f.httpClient(d)
|
||||
prepare := func() {
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/protected", nil)
|
||||
if err := d.Prepare(t.Context(), client, srv.URL, req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
prepare()
|
||||
prepare()
|
||||
if n := l.allowed.Load(); n != 1 {
|
||||
t.Fatalf("logins within the lifetime = %d, want 1", n)
|
||||
}
|
||||
ahead := time.Until(time.Unix(0, d.dialer.renewAt.Load()))
|
||||
if ahead <= 0 || ahead > MinTokenLifetime-5*time.Second {
|
||||
t.Fatalf("renewal scheduled %v ahead, want within lifetime - 5 s", ahead)
|
||||
}
|
||||
d.dialer.renewAt.Store(time.Now().UnixNano()) // the renewal point arrives
|
||||
prepare()
|
||||
if n := l.allowed.Load(); n != 2 {
|
||||
t.Fatalf("logins after the renewal point = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user