v0.21.0 scram password auth, cred and token management, browser tls-terminating proxy, viewer client
Test / test (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-02 15:25:51 -04:00
1 parent f192c26fa2
commit 9ab8c356d2
60 files changed
+6791 -747

No files matched your search

+388 -107
View File
@@ -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
+70 -26
View File
@@ -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)
}
+204
View File
@@ -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
}
+257
View File
@@ -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)
}
}
+996
View File
@@ -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)
}
}
}
+736
View File
@@ -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)
}
}
+13 -11
View File
@@ -11,28 +11,30 @@ import (
// ProtocolVersion is declared in the hello preamble
const ProtocolVersion = 1
// Hello is the first NDJSON line sent by the dialing side after connect.
// Reserved for future revisions: auth credential, feature flags (ack, compression).
// Hello is the first NDJSON line a dialer sends after connect. Fields are
// optional so older peers ignore what they do not know; a field a peer must
// understand needs a ProtocolVersion bump. Scram opens an authentication
// exchange (authz owns its content), so chain need not know the auth library.
type Hello struct {
LogWisp int `json:"logwisp"`
Node string `json:"node,omitempty"`
// Auth string `json:"auth,omitempty"`
// Features []string `json:"features,omitempty"`
LogWisp int `json:"logwisp"`
Node string `json:"node,omitempty"`
Scram json.RawMessage `json:"scram,omitempty"`
}
// HTTP transport mapping of the chain protocol.
// Hello preamble equivalent: protocol + node carried as request headers.
// Reserved extension point: Authorization header for auth, TLS at transport.
// HTTP transport mapping of the chain protocol: protocol and node travel as
// request headers, SCRAM runs on AuthPath and yields a bearer token.
const (
HeaderProtocol = "X-Logwisp-Protocol"
HeaderNode = "X-Logwisp-Node"
HeaderAccepted = "X-Logwisp-Accepted"
ContentTypeNDJSON = "application/x-ndjson"
AuthPath = "/auth"
)
// EncodeHello serializes a newline-terminated hello preamble
func EncodeHello(node string) ([]byte, error) {
b, err := json.Marshal(Hello{LogWisp: ProtocolVersion, Node: node})
func EncodeHello(h Hello) ([]byte, error) {
h.LogWisp = ProtocolVersion
b, err := json.Marshal(h)
if err != nil {
return nil, err
}
+33 -50
View File
@@ -172,15 +172,6 @@ type PluginSourceConfig struct {
ConfigFile string `toml:"config_file,omitempty"` // TODO: support for include/source mechanism for nested config
}
// // SourceConfig is a polymorphic struct representing a single data source
// type SourceConfig struct {
// Type string `toml:"type"`
//
// // Polymorphic - only one populated based on type
// File *FileSourceOptions `toml:"file,omitempty"`
// Console *ConsoleSourceOptions `toml:"console,omitempty"`
// }
// NullSourceOptions defines settings for a null source (no configuration needed)
type NullSourceOptions struct{}
@@ -219,7 +210,6 @@ type TCPChainSourceOptions struct {
HelloTimeoutMS int64 `toml:"hello_timeout_ms"` // preamble deadline
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
Auth *AuthOptions `toml:"auth"`
// Future: password auth block
}
// HTTPChainSourceOptions defines settings for a stdlib HTTP listener ingesting
@@ -234,7 +224,6 @@ type HTTPChainSourceOptions struct {
ReadTimeoutMS int64 `toml:"read_timeout_ms"` // full request read deadline
TrustNode bool `toml:"trust_node"` // false: force node label from remote address
Auth *AuthOptions `toml:"auth"`
// Future: password auth block
}
// --- Sink Options ---
@@ -247,15 +236,6 @@ type PluginSinkConfig struct {
ConfigFile string `toml:"config_file,omitempty"` // TODO: support for include/source mechanism for nested config
}
// // SinkConfig is a polymorphic struct representing a single data sink
// type SinkConfig struct {
// Type string `toml:"type"`
//
// // Polymorphic - only one populated based on type
// Console *ConsoleSinkOptions `toml:"console,omitempty"`
// File *FileSinkOptions `toml:"file,omitempty"`
// }
// NullSinkOptions defines settings for a null sink (no configuration needed)
type NullSinkOptions struct{}
@@ -289,7 +269,6 @@ type TCPSinkOptions struct {
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
Auth *AuthOptions `toml:"auth"`
// Future: password auth block
}
// HTTPSinkOptions defines settings for an HTTP SSE server sink
@@ -303,8 +282,9 @@ type HTTPSinkOptions struct {
ClientBufferSize int64 `toml:"client_buffer_size"` // per-client send queue
WriteTimeoutMS int64 `toml:"write_timeout_ms"` // per-SSE-write deadline, 0 = none
MaxConnections int64 `toml:"max_connections"` // 0 = unlimited
LoginPage bool `toml:"login_page"` // GET /auth/login, needs auth.trusted_proxies
ViewerPage bool `toml:"viewer_page"` // GET /auth/view, needs login_page
Auth *AuthOptions `toml:"auth"`
// Future: password auth block
}
// TCPChainSinkOptions defines settings for a stdlib TCP client forwarding
@@ -322,7 +302,6 @@ type TCPChainSinkOptions struct {
KeepAlivePeriodMS int64 `toml:"keep_alive_period_ms"`
KeepAlive bool `toml:"keep_alive"`
Auth *AuthOptions `toml:"auth"`
// Future: password auth block
}
// HTTPChainSinkOptions defines settings for a stdlib HTTP client posting
@@ -341,49 +320,53 @@ type HTTPChainSinkOptions struct {
BackoffMinMS int64 `toml:"backoff_min_ms"`
BackoffMaxMS int64 `toml:"backoff_max_ms"`
Auth *AuthOptions `toml:"auth"`
// Future: password auth block
}
// --- Auth Options ---
// AuthOptions defines certificate-based authorization for network plugins.
// It sits beside `tls` rather than inside it: TLS answers "is this channel
// private and does the peer chain to a CA", auth answers "may *this* peer do
// *this*". One shape serves both roles:
// - Listeners (tcp/http sinks, tcp_chain/http_chain sources) authorize the
// peer's client certificate; type "mtls" requires tls.client_auth.
// - Dialers (tcp_chain/http_chain sinks) pin the server's identity beyond
// hostname verification.
// AuthOptions selects how a network plugin authenticates its peer. It sits
// beside `tls`: TLS answers "is this channel private", auth answers "may this
// peer do this". Listeners verify (mtls: certificate identity; scram: password
// via credentials_file); dialers prove themselves (scram) or pin the server
// (mtls). Validation is per role in internal/authz.
type AuthOptions struct {
// Method: "none" (default, preserves pre-auth behavior) | "mtls"
// Method: "none" (default) | "mtls" | "scram"
Type string `toml:"type"`
// Certificate field carrying the identity:
// "cn" (default) | "san_dns" | "san_uri" | "san_email"
// Certificate field carrying the identity: "cn" (default) | "san_dns" |
// "san_uri" | "san_email". Under scram it binds the client certificate to
// the user: the field must equal the SCRAM username.
Identity string `toml:"identity"`
// Exact identity matches. Empty Allow *and* AllowPatterns means "any
// identity the CA vouches for" - today's behavior, but with the identity
// recorded and node binding available.
Allow []string `toml:"allow"`
// RE2 patterns matched against the identity; anchor them yourself
// mtls only: exact identities and RE2 patterns (anchor them yourself).
// Both empty admits any identity the CA vouches for.
Allow []string `toml:"allow"`
AllowPatterns []string `toml:"allow_patterns"`
// Chain sources only: "none" | "assert" | "force" (default "force" when
// Type is "mtls"). Overrides trust_node.
// Chain sources only: "none" | "assert" | "force" (default "force").
// Overrides trust_node.
NodeBinding string `toml:"node_binding"`
// scram listeners: verifier file written by `logwisp auth add-user`, and
// the bearer token lifetime on HTTP listeners (default 15 minutes)
CredentialsFile string `toml:"credentials_file"`
TokenLifetimeMS int64 `toml:"token_lifetime_ms"`
// scram http sink: addresses or CIDRs of the reverse proxies that end the
// browsers' TLS; logins are then unbound and sessions may be cookies
TrustedProxies []string `toml:"trusted_proxies"`
// scram dialers: the identity presented and the file holding its password
Username string `toml:"username"`
PasswordFile string `toml:"password_file"`
}
// --- TLS Options ---
// TLSOptions defines transport security for network sources and sinks.
// One shape serves both roles so the config block is uniform:
// - Listeners (tcp/http sinks, tcp_chain/http_chain sources) use
// cert_file/key_file as server identity; client_auth/client_ca_file
// require and verify peer certificates (mTLS).
// - Dialers (tcp_chain/http_chain sinks) use ca_file/server_name to verify
// the server; cert_file/key_file present a client identity (mTLS).
// TLSOptions is one shape for both roles. Listeners present cert_file and
// key_file and verify clients with client_auth and client_ca_file; dialers
// verify the server with ca_file and server_name and may present
// cert_file and key_file.
type TLSOptions struct {
Enabled bool `toml:"enabled"`
+3
View File
@@ -65,6 +65,9 @@ func (m *Manager) Snapshot() (*Config, error) {
}
cfg := value.(*Config)
cfg.ConfigFile = m.path
if err := checkFileKeys(m.path); err != nil {
return nil, fmt.Errorf("config file %q: %w", m.path, err)
}
if err := ValidateConfig(cfg); err != nil {
return nil, fmt.Errorf("validate configuration: %w", err)
}
+32
View File
@@ -228,3 +228,35 @@ port = 9000
}
}
}
// A misspelled table path above a plugin's config drops that whole table, so
// the file itself is checked too; only config_file is tolerated, as documented.
func TestConfigFileRejectsUnknownKeys(t *testing.T) {
isolateConfig(t)
for key, body := range map[string]string{
"pipelines[0].plugin_sinks[0].confg": "[pipelines.plugin_sinks.confg.tls]\nenabled = true",
"logging.levle": "[logging]\nlevle = \"debug\"",
} {
testutil.WriteFile(t, "typo.toml", `config_file = "ignored.toml"
[[pipelines]]
name = "typo"
[[pipelines.plugin_sources]]
id = "in"
type = "null"
[[pipelines.plugin_sinks]]
id = "out"
type = "tcp"
[pipelines.plugin_sinks.config]
port = 9000
`+body+"\n")
m, err := Load([]string{"-c", "typo.toml"})
if err != nil {
t.Fatal(err)
}
_, err = m.Snapshot()
m.Close()
if err == nil || !strings.Contains(err.Error(), `"`+key+`"`) {
t.Errorf("unknown key %s: err = %v", key, err)
}
}
}
+28 -3
View File
@@ -1,13 +1,17 @@
package config
import (
"errors"
"fmt"
"io/fs"
"maps"
"os"
"reflect"
"slices"
"strings"
lconfig "github.com/lixenwraith/config"
"github.com/lixenwraith/toml"
)
// ValidateConfig validates top-level structure only
@@ -119,7 +123,9 @@ func unknownKeys(m map[string]any, t reflect.Type, prefix string) error {
var tables []map[string]any
switch v := m[key].(type) {
case map[string]any:
tables = []map[string]any{v}
if err := unknownKeys(v, ft, prefix+key+"."); err != nil {
return err
}
case []map[string]any:
tables = v
case []any:
@@ -129,11 +135,30 @@ func unknownKeys(m map[string]any, t reflect.Type, prefix string) error {
}
}
}
for _, table := range tables {
if err := unknownKeys(table, ft, prefix+key+"."); err != nil {
for i, table := range tables {
if err := unknownKeys(table, ft, fmt.Sprintf("%s%s[%d].", prefix, key, i)); err != nil {
return err
}
}
}
return nil
}
// checkFileKeys rejects keys the configuration file declares that Config does
// not: a misspelled table path such as plugin_sinks.confg.tls would otherwise
// drop the whole table. Plugin config maps are checked by Scan instead.
func checkFileKeys(path string) error {
data, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
root, err := toml.NewParser(data).Parse()
if err != nil {
return err
}
delete(root, "config_file") // runtime metadata, documented as ignored in the file
return unknownKeys(root, reflect.TypeOf(Config{}), "")
}
+2 -1
View File
@@ -7,6 +7,7 @@ const (
// Network capabilities
CapNetLimit Capability = "netlimit"
CapTLS Capability = "tls"
CapProxyTLS Capability = "proxy_tls" // TLS ends at a trusted reverse proxy
CapAuth Capability = "auth"
// Session capabilities
@@ -17,4 +18,4 @@ const (
// Stream capabilities
CapBidirectional Capability = "bidirectional"
CapCompression Capability = "compression"
)
)
+2 -2
View File
@@ -161,7 +161,7 @@ func (p *Pipeline) initSourceCapabilities(s source.Source, cfg config.PluginSour
// Network capabilities
case core.CapNetLimit:
continue // No-op for now, placeholder
case core.CapTLS:
case core.CapTLS, core.CapProxyTLS:
hasTLS = true
case core.CapAuth:
hasAuth = true
@@ -202,7 +202,7 @@ func (p *Pipeline) initSinkCapabilities(s sink.Sink, cfg config.PluginSinkConfig
// Network capabilities
case core.CapNetLimit:
continue // No-op for now, placeholder
case core.CapTLS:
case core.CapTLS, core.CapProxyTLS:
hasTLS = true
case core.CapAuth:
hasAuth = true
+88 -27
View File
@@ -1,6 +1,7 @@
package http
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
@@ -16,6 +17,7 @@ import (
"time"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/plugin"
@@ -75,6 +77,7 @@ type HTTPSink struct {
// Authorization
auth *authz.Policy
web map[string]http.Handler // proxy mode: GET /auth/... browser files
// Runtime
done chan struct{}
@@ -126,6 +129,11 @@ func NewHTTPSinkPlugin(
if opts.StreamPath == opts.StatusPath {
return nil, fmt.Errorf("stream_path and status_path must differ")
}
for _, p := range []string{opts.StreamPath, opts.StatusPath} {
if p == chain.AuthPath || strings.HasPrefix(p, chain.AuthPath+"/") {
return nil, fmt.Errorf("%s and the paths under it are reserved for authentication", chain.AuthPath)
}
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultHTTPBufferSize
}
@@ -136,10 +144,22 @@ func NewHTTPSinkPlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleListener)
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleListener, authz.HTTP)
if err != nil {
return nil, err
}
switch {
case (opts.LoginPage || opts.ViewerPage) && !authPolicy.BehindProxy():
return nil, errors.New("login_page and viewer_page need auth.trusted_proxies: browsers log in only behind a TLS-terminating proxy")
case opts.ViewerPage && !opts.LoginPage:
return nil, errors.New("viewer_page needs login_page, where it sends a signed-out viewer")
}
var web map[string]http.Handler
if authPolicy.BehindProxy() {
if web, err = webHandlers(opts); err != nil {
return nil, err
}
}
h := &HTTPSink{
id: id,
@@ -154,6 +174,7 @@ func NewHTTPSinkPlugin(
keepalive: core.StreamKeepaliveInterval,
tlsConfig: tlsCfg,
auth: authPolicy,
web: web,
}
h.lastProcessed.Store(time.Time{})
@@ -166,7 +187,9 @@ func NewHTTPSinkPlugin(
"status_path", opts.StatusPath,
"tls", tlsCfg != nil,
"mtls", tlsCfg != nil && tlsCfg.ClientAuth == tls.RequireAndVerifyClientCert,
"auth", authPolicy.Describe())
"auth", authPolicy.Describe(),
"login_page", opts.LoginPage,
"viewer_page", opts.ViewerPage)
tlsx.LogWarnings(logger, "http_sink", id, opts.TLS, true)
authPolicy.LogStartup(logger, "http_sink", id, false)
return h, nil
@@ -178,6 +201,9 @@ func (h *HTTPSink) Capabilities() []core.Capability {
if h.tlsConfig != nil {
caps = append(caps, core.CapTLS)
}
if h.auth.BehindProxy() {
caps = append(caps, core.CapProxyTLS)
}
if h.auth.Enabled() {
caps = append(caps, core.CapAuth) // authorizes clients, not just the CA
}
@@ -204,6 +230,10 @@ func (h *HTTPSink) Start(ctx context.Context) error {
// serve owns an already-bound listener, allowing tests to reserve an ephemeral
// port and exercise the same routing and worker lifecycle as Start.
func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
if err := h.auth.Start(); err != nil {
ln.Close()
return err
}
mux := http.NewServeMux()
// Method-scoped patterns: mux answers 405 with Allow header on non-GET
mux.HandleFunc(http.MethodGet+" "+h.config.StreamPath, h.handleStream)
@@ -214,10 +244,20 @@ func (h *HTTPSink) serve(ctx context.Context, ln net.Listener) error {
mux.HandleFunc(http.MethodHead+" "+h.config.StreamPath, streamHeadNotAllowed)
// One wrapper covers stream and status, and keeps the handlers themselves
// unaware of authorization
// unaware of authorization. Login and its browser files sit outside it;
// in proxy mode everything sits behind the proxy gate.
var handler http.Handler = mux
if h.auth.Enabled() {
handler = h.authMiddleware(handler)
outer := http.NewServeMux()
outer.HandleFunc(http.MethodPost+" "+chain.AuthPath, h.handleAuth)
for p, file := range h.web {
outer.Handle(http.MethodGet+" "+p, file)
}
outer.Handle("/", h.authMiddleware(mux))
handler = outer
if h.auth.BehindProxy() {
handler = h.proxyGate(outer)
}
}
h.server = &http.Server{
@@ -271,6 +311,7 @@ func (h *HTTPSink) Stop() {
h.shutdown()
h.wg.Wait()
h.auth.Close()
h.logger.Info("msg", " HTTP sink stopped",
"component", "http_sink",
@@ -358,7 +399,7 @@ func (h *HTTPSink) handleStream(w http.ResponseWriter, r *http.Request) {
}
rc := http.NewResponseController(w)
remote := r.RemoteAddr
remote := clientAddr(r)
meta := map[string]any{
"type": "http_client",
@@ -549,34 +590,53 @@ func (h *HTTPSink) GetStats() sink.SinkStats {
// handlers; absent when auth is disabled
type identityKey struct{}
// authMiddleware gates every endpoint on the client certificate policy.
// The rejection carries no detail: the status endpoint already exposes host,
// port, and throughput counters, so a 403 should not add the shape of the
// policy on top of that.
// authMiddleware gates every endpoint on the policy: a client certificate
// or a bearer token. The rejection carries no detail: the status endpoint
// already exposes host, port, and throughput counters, so a refusal should
// not add the shape of the policy on top of that.
func (h *HTTPSink) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ident, err := h.auth.Authorize(r.TLS)
ident, status, err := h.auth.AuthorizeRequest(r)
if err != nil {
h.logger.Warn("msg", "Request rejected by auth policy",
"component", "http_sink",
"instance_id", h.id,
"remote_addr", r.RemoteAddr,
"remote_addr", clientAddr(r),
"path", r.URL.Path,
"error", err)
http.Error(w, "forbidden", http.StatusForbidden)
authz.Refuse(w, status)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), identityKey{}, ident)))
})
}
// handleAuth runs one step of a SCRAM login and logs its outcome
func (h *HTTPSink) handleAuth(w http.ResponseWriter, r *http.Request) {
ident, err := h.auth.ServeAuth(w, r)
switch {
case err != nil:
h.logger.Warn("msg", "Login rejected",
"component", "http_sink",
"instance_id", h.id,
"remote_addr", clientAddr(r),
"error", err)
case ident.Name != "":
h.logger.Info("msg", "Login accepted",
"component", "http_sink",
"instance_id", h.id,
"remote_addr", clientAddr(r),
"auth_identity", ident.Name)
}
}
// streamHeadNotAllowed refuses a body-less read of a stream that is only a body
func streamHeadNotAllowed(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
// writeSSE frames a payload per the W3C SSE spec (multi-line safe)
// writeSSE frames a payload per the W3C SSE spec, one data: line per line
func writeSSE(w http.ResponseWriter, payload []byte) error {
for _, line := range splitLines(payload) {
if _, err := fmt.Fprintf(w, "data: %s\n", line); err != nil {
@@ -587,27 +647,28 @@ func writeSSE(w http.ResponseWriter, payload []byte) error {
return err
}
// splitLines splits payload by newlines, trimming a single trailing newline
// splitLines splits on every break SSE recognises (CRLF, LF and a lone CR),
// so no payload byte can end a data: line and start an event: or retry:
// field. One trailing break is dropped.
func splitLines(data []byte) [][]byte {
if len(data) == 0 {
return nil
}
if data[len(data)-1] == '\n' {
if bytes.HasSuffix(data, []byte("\r\n")) {
data = data[:len(data)-2]
} else if data[len(data)-1] == '\n' || data[len(data)-1] == '\r' {
data = data[:len(data)-1]
}
var lines [][]byte
start := 0
for i := 0; i < len(data); i++ {
if data[i] == '\n' {
lines = append(lines, data[start:i])
start = i + 1
for {
i := bytes.IndexAny(data, "\r\n")
if i < 0 {
return append(lines, data)
}
lines = append(lines, data[:i])
if data[i] == '\r' && i+1 < len(data) && data[i+1] == '\n' {
i++
}
data = data[i+1:]
}
if start < len(data) {
lines = append(lines, data[start:])
}
if len(lines) == 0 {
return [][]byte{data}
}
return lines
}
+150
View File
@@ -2,18 +2,26 @@ package http
import (
"bufio"
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"io"
"maps"
"net/http"
"net/http/httptest"
"os"
"slices"
"strings"
"testing"
"time"
"logwisp/internal/core"
"logwisp/internal/sink"
"logwisp/internal/testutil"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/log"
)
func TestStatusReportsQueueAndConnectionBounds(t *testing.T) {
@@ -169,3 +177,145 @@ func TestWildcardCORSOnlyWithoutAuth(t *testing.T) {
}
}
}
// Under scram the login endpoint sits outside the gate it opens: a hello
// earns a challenge without a token, while stream and status demand one.
func TestLoginEndpointBypassesTheGate(t *testing.T) {
pki := testutil.NewPKI(t, "viewer-01")
creds := scramCredentials(t)
gated, _ := newTestHTTPSink(t, map[string]any{
"tls": map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey},
"auth": map[string]any{"type": "scram", "credentials_file": creds},
})
client, baseURL := serveTestHTTPSink(t, gated)
caPEM, err := os.ReadFile(pki.CA)
if err != nil {
t.Fatal(err)
}
roots := x509.NewCertPool()
roots.AppendCertsFromPEM(caPEM)
client.Transport = &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots}}
baseURL = "https" + strings.TrimPrefix(baseURL, "http")
hello := `{"logwisp":1,"scram":{"username":"viewer-01","client_nonce":"abcdefgh"}}`
resp, err := client.Post(baseURL+"/auth", "application/json", strings.NewReader(hello))
if err != nil {
t.Fatal(err)
}
var step struct {
Challenge *auth.ServerFirstMessage `json:"challenge"`
}
json.NewDecoder(resp.Body).Decode(&step)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || step.Challenge == nil {
t.Fatalf("POST /auth without a token = %d, challenge %v", resp.StatusCode, step.Challenge)
}
for _, path := range []string{"/status", "/stream"} {
resp, err := client.Get(baseURL + path)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("GET %s without a token = %d, want 401", path, resp.StatusCode)
}
}
}
// proxied sends what a TLS-terminating proxy would: the client and https
func proxied(req *http.Request) *http.Request {
req.Header.Set("X-Forwarded-For", "203.0.113.7")
req.Header.Set("X-Forwarded-Proto", "https")
return req
}
func proxySink(t *testing.T, proxies []any, overrides map[string]any) *HTTPSink {
t.Helper()
opts := map[string]any{"auth": map[string]any{"type": "scram", "credentials_file": scramCredentials(t), "trusted_proxies": proxies}}
maps.Copy(opts, overrides)
h, _ := newTestHTTPSink(t, opts)
return h
}
// Behind a proxy the sink serves the client library and the enabled pages
// under /auth/, each with its type and the pages' CSP; the viewer learns a
// custom status path from its meta tag.
func TestProxyModeServesBrowserFiles(t *testing.T) {
h := proxySink(t, []any{"127.0.0.1"}, map[string]any{"login_page": true, "viewer_page": true, "status_path": "/api/status"})
if !slices.Contains(h.Capabilities(), core.CapProxyTLS) {
t.Fatal("a proxy-mode sink does not report proxy_tls")
}
client, baseURL := serveTestHTTPSink(t, h)
for file, ctype := range map[string]string{
"scram.js": "text/javascript", "login": "text/html", "login.js": "text/javascript",
"style.css": "text/css", "view": "text/html", "view.js": "text/javascript",
} {
req, _ := http.NewRequest(http.MethodGet, baseURL+"/auth/"+file, nil)
resp, err := client.Do(proxied(req))
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.HasPrefix(resp.Header.Get("Content-Type"), ctype) ||
!strings.Contains(resp.Header.Get("Content-Security-Policy"), "script-src 'self'") {
t.Errorf("/auth/%s: %d %q %q", file, resp.StatusCode, resp.Header.Get("Content-Type"), resp.Header.Get("Content-Security-Policy"))
}
if file == "view" && !bytes.Contains(body, []byte(`content="api/status"`)) {
t.Error("the viewer page does not carry the custom status path")
}
}
}
// Behind a proxy nothing answers a peer outside trusted_proxies, not even the
// login page or the challenge.
func TestProxyModeRefusesDirectPeers(t *testing.T) {
h := proxySink(t, []any{"192.0.2.1"}, map[string]any{"login_page": true})
client, baseURL := serveTestHTTPSink(t, h)
for _, target := range []struct{ method, path string }{
{http.MethodGet, "/auth/login"}, {http.MethodGet, "/status"}, {http.MethodPost, "/auth"},
} {
req, _ := http.NewRequest(target.method, baseURL+target.path, strings.NewReader(`{"logwisp":1}`))
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(proxied(req))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s from an untrusted peer: %d, want 403", target.method, target.path, resp.StatusCode)
}
}
}
// Pages exist only behind a proxy, where browsers can log in; the viewer
// needs the login page; /auth and the paths under it are reserved.
func TestPagesNeedProxyMode(t *testing.T) {
scram := map[string]any{"type": "scram", "credentials_file": scramCredentials(t)}
pki := testutil.NewPKI(t, "viewer-01")
tlsOn := map[string]any{"enabled": true, "cert_file": pki.ServerCert, "key_file": pki.ServerKey}
proxy := map[string]any{"type": "scram", "credentials_file": scram["credentials_file"], "trusted_proxies": []any{"127.0.0.1"}}
for name, opts := range map[string]map[string]any{
"login page without proxies": {"tls": tlsOn, "auth": scram, "login_page": true},
"viewer without login": {"auth": proxy, "viewer_page": true},
"stream under /auth": {"auth": proxy, "stream_path": "/auth/stream"},
} {
maps.Copy(opts, map[string]any{"host": "127.0.0.1", "port": int64(8081)})
if _, err := NewHTTPSinkPlugin("stream", opts, log.NewLogger(), nil); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
// Every line break SSE recognises, a lone CR included, starts another data:
// line: an entry cannot inject an event:, retry: or id: field.
func TestSSEFramesEveryLineAsData(t *testing.T) {
rec := httptest.NewRecorder()
if err := writeSSE(rec, []byte("a\revent: disconnect\r\nretry: 99999999\nid: x\r")); err != nil {
t.Fatal(err)
}
want := "data: a\ndata: event: disconnect\ndata: retry: 99999999\ndata: id: x\n\n"
if got := rec.Body.String(); got != want {
t.Fatalf("framed %q, want %q", got, want)
}
}
+21
View File
@@ -5,11 +5,15 @@ import (
"maps"
"net"
"net/http"
"path/filepath"
"testing"
"time"
"logwisp/internal/authz"
"logwisp/internal/session"
"logwisp/internal/testutil"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/log"
)
@@ -46,3 +50,20 @@ func serveTestHTTPSink(t *testing.T, h *HTTPSink) (*http.Client, string) {
t.Cleanup(client.CloseIdleConnections)
return client, "http://" + listener.Addr().String()
}
// scramCredentials writes a credentials file holding viewer-01 under a cheap
// Argon2 profile
func scramCredentials(t *testing.T) string {
t.Helper()
cred, err := auth.NewCredential("viewer-01", "viewer-01-secret", auth.WithTime(1), auth.WithMemory(64), auth.WithThreads(1))
if err != nil {
t.Fatal(err)
}
data, err := (&authz.Credentials{DecoyKey: make([]byte, 32), Users: []*auth.Credential{cred}}).Marshal()
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "users.toml")
testutil.WriteFile(t, path, string(data))
return path
}
+110
View File
@@ -0,0 +1,110 @@
package http
import (
"bytes"
"context"
"embed"
"html"
"net/http"
"path"
"strings"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
)
//go:embed web/*.js web/*.html web/*.css
var webFiles embed.FS
// pageCSP confines the pages to their own scripts and styles: no inline code,
// no other origin, no framing
const pageCSP = "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'self'; " +
"form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
var webTypes = map[string]string{
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".html": "text/html; charset=utf-8",
}
func loginOn(o *config.HTTPSinkOptions) bool { return o.LoginPage }
func viewerOn(o *config.HTTPSinkOptions) bool { return o.ViewerPage }
// webRoutes are served under /auth/ in proxy mode: the client library always,
// so a site can load it, and the pages when enabled
var webRoutes = []struct {
name, file string
on func(*config.HTTPSinkOptions) bool
}{
{"scram.js", "scram.js", func(*config.HTTPSinkOptions) bool { return true }},
{"style.css", "style.css", loginOn},
{"login", "login.html", loginOn},
{"login.js", "login.js", loginOn},
{"view", "view.html", viewerOn},
{"view.js", "view.js", viewerOn},
}
// webHandlers maps each enabled GET path to its file. The viewer learns a
// custom status path from a meta tag, as the CSP allows no inline script.
func webHandlers(o *config.HTTPSinkOptions) (map[string]http.Handler, error) {
handlers := make(map[string]http.Handler)
for _, r := range webRoutes {
if !r.on(o) {
continue
}
data, err := webFiles.ReadFile("web/" + r.file)
if err != nil {
return nil, err
}
if r.file == "view.html" {
status := html.EscapeString(strings.TrimPrefix(o.StatusPath, "/"))
data = bytes.Replace(data, []byte(`content="status"`), []byte(`content="`+status+`"`), 1)
}
handlers[chain.AuthPath+"/"+r.name] = serveWebFile(data, webTypes[path.Ext(r.file)])
}
return handlers, nil
}
func serveWebFile(data []byte, contentType string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hd := w.Header()
hd.Set("Content-Type", contentType)
hd.Set("Content-Security-Policy", pageCSP)
hd.Set("X-Content-Type-Options", "nosniff")
hd.Set("Referrer-Policy", "no-referrer")
hd.Set("Cache-Control", "no-cache")
w.Write(data)
})
}
// clientKey carries the forwarded client address from proxyGate
type clientKey struct{}
// proxyGate admits only the trusted proxies, and records the client they
// forward for logs and sessions. ServeAuth and AuthorizeRequest check again.
func (h *HTTPSink) proxyGate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
addr, err := h.auth.ClientAddr(r)
if err != nil {
h.logger.Warn("msg", "Request refused: not through a trusted proxy over https",
"component", "http_sink",
"instance_id", h.id,
"remote_addr", r.RemoteAddr,
"path", r.URL.Path,
"error", err)
authz.Refuse(w, http.StatusForbidden)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientKey{}, addr)))
})
}
// clientAddr names the client in logs and sessions: the forwarded client in
// proxy mode, else the socket peer
func clientAddr(r *http.Request) string {
if addr, ok := r.Context().Value(clientKey{}).(string); ok {
return addr
}
return r.RemoteAddr
}
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LogWisp sign-in</title>
<link rel="stylesheet" href="style.css">
<script type="module" src="login.js"></script>
</head>
<body>
<main class="card">
<h1>LogWisp</h1>
<form id="login" method="post">
<label>Username <input name="username" autocomplete="username" required></label>
<label>Password <input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit" disabled>Sign in</button>
</form>
<progress id="progress" max="1" hidden></progress>
<p id="status" role="status"></p>
</main>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
import { login } from "./scram.js";
const base = new URL("..", location.href);
const form = document.getElementById("login");
const button = form.querySelector("button");
const progress = document.getElementById("progress");
const status = document.getElementById("status");
// Same origin only: an open redirect would lend this page to phishing
function nextTarget() {
const next = new URLSearchParams(location.search).get("next");
if (!next) return null;
try {
const url = new URL(next, location.href);
return url.origin === location.origin ? url : null;
} catch {
return null;
}
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
const fields = new FormData(form);
button.disabled = true;
progress.removeAttribute("value");
progress.hidden = false;
status.textContent = "Deriving the key, this takes a few seconds…";
try {
const session = await login(base, fields.get("username"), fields.get("password"), {
onProgress: (fraction) => { progress.value = fraction; },
});
form.elements.password.value = "";
const target = nextTarget();
if (target) {
location.replace(target.href);
return;
}
status.textContent = `Signed in as ${session.username}.`;
} catch (err) {
status.textContent = err.message;
} finally {
progress.hidden = true;
button.disabled = false;
}
});
// Enabled only once this script runs, so a failed load cannot submit the form natively
button.disabled = false;
+484
View File
@@ -0,0 +1,484 @@
const MAX_USERNAME = 256;
const MAX_PASSWORD = 1024;
const MAX_FULL_NONCE = 512;
const MIN_SALT = 16;
const MAX_SALT = 64;
// lixenwraith/auth's budget for an Argon2 profile it did not choose
const MAX_TIME = 16;
const MAX_MEMORY = 256 * 1024;
const MAX_THREADS = 16;
const MAX_WORK = 4 * 64 * 1024 * 3;
// The Go dialer's floor: a hostile server must not obtain a cheaply guessable proof
const MIN_TIME = 3;
const MIN_MEMORY = 64 * 1024;
const encoder = new TextEncoder();
/**
* Logs in to the logwisp http sink mounted at base (a URL ending in "/"), per
* the wire protocol in logwisp's doc/scram-auth-plan.md. The session arrives as
* an HttpOnly cookie; the promise resolves only once the server has proved it
* holds this user's verifier. onProgress(fraction) follows the Argon2 pass.
*/
export async function login(base, username, password, { onProgress } = {}) {
const pw = checkCredentials(username, password);
subtle(); // an insecure page fails before anything is sent
const url = new URL("auth", base);
const clientNonce = base64(crypto.getRandomValues(new Uint8Array(24)))
.replace(/\+/g, "-").replace(/\//g, "_");
const first = await post(url, { logwisp: 1, scram: { username, client_nonce: clientNonce } });
const { proof, serverSignature } = await prove(username, pw, clientNonce, first.challenge,
MIN_TIME, MIN_MEMORY, onProgress);
const step = await post(url, { proof, session: "cookie" });
const final = step.final;
if (!final || typeof final !== "object") {
throw new Error("server sent no final message");
}
const name = final.username ?? "";
const signature = decodeBase64(final.server_signature, 32);
if ((name !== "" && name !== username) || !signature || !equal(signature, serverSignature)) {
throw new Error("server could not prove it holds this user's verifier");
}
const expiresIn = Number.isSafeInteger(step.expires_in) && step.expires_in > 0 ? step.expires_in : 0;
return { username, expiresIn };
}
// Logout posts to auth itself: a clearing cookie from any other path would
// get another default path and miss the session cookie
export async function logout(base) {
const res = await send(new URL("auth", base), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ logout: true }),
});
if (res.status !== 200 && res.status !== 204) {
throw new Error(`logout failed: ${res.status}`);
}
}
// Test hook: the proof for a fixed client nonce, under an explicit Argon2 floor
export async function _proofForTest(username, password, clientNonce, challenge, minTime, minMemory) {
const { proof, serverSignature } = await prove(username, checkCredentials(username, password),
clientNonce, challenge, minTime, minMemory);
return { client_proof: proof.client_proof, server_signature: base64(serverSignature) };
}
function checkCredentials(username, password) {
if (typeof username !== "string" || typeof password !== "string") {
throw new Error("username and password must be text");
}
const name = encoder.encode(username);
if (name.length === 0 || name.length > MAX_USERNAME || /[,=\p{Cc}\p{Cs}]/u.test(username)) {
throw new Error("invalid username");
}
// TextEncoder would silently turn a lone surrogate into U+FFFD
if (/\p{Cs}/u.test(password)) {
throw new Error("password contains invalid characters");
}
const pw = encoder.encode(password);
if (pw.length === 0 || pw.length > MAX_PASSWORD) {
throw new Error(`password must be 1-${MAX_PASSWORD} bytes`);
}
return pw;
}
// Everything the challenge says is checked before Argon2 runs on it
async function prove(username, pw, clientNonce, challenge, minTime, minMemory, onProgress) {
if (!challenge || typeof challenge !== "object") {
throw new Error("server sent no challenge");
}
const { full_nonce: fullNonce, salt, argon_time: t, argon_memory: m, argon_threads: p } = challenge;
if (typeof fullNonce !== "string" || fullNonce.length > MAX_FULL_NONCE || !/^[\x21-\x2b\x2d-\x7e]+$/.test(fullNonce) ||
!fullNonce.startsWith(clientNonce) || fullNonce.length <= clientNonce.length) {
throw new Error("invalid server nonce");
}
const saltBytes = decodeBase64(salt, MAX_SALT);
if (!saltBytes || saltBytes.length < MIN_SALT) {
throw new Error("invalid salt");
}
if (![t, m, p].every(Number.isSafeInteger) || t < 1 || p < 1 || m < 8 * p ||
t > MAX_TIME || m > MAX_MEMORY || p > MAX_THREADS || m * t > MAX_WORK) {
throw new Error("invalid Argon2 parameters");
}
if (t < minTime || m < minMemory) {
throw new Error("server asked for a weaker Argon2 cost than allowed");
}
const salted = await argon2id(pw, saltBytes, t, m, p, 32, { onProgress });
const clientKey = await hmac(salted, "Client Key");
const serverKey = await hmac(salted, "Server Key");
const storedKey = new Uint8Array(await subtle().digest("SHA-256", clientKey));
salted.fill(0);
// Unbound: the browser cannot see the certificate, so no c= part
const authMessage = `u=${username},n=${clientNonce},r=${fullNonce},s=${salt},t=${t},m=${m},p=${p},r=${fullNonce}`;
const proof = await hmac(storedKey, authMessage);
for (let i = 0; i < proof.length; i++) proof[i] ^= clientKey[i];
clientKey.fill(0);
return {
proof: { full_nonce: fullNonce, client_proof: base64(proof) },
serverSignature: await hmac(serverKey, authMessage),
};
}
async function post(url, body) {
const res = await send(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const step = (await res.json().catch(() => null)) ?? {};
if (res.status === 404 || res.status === 405) {
throw new Error("no SCRAM login at this address");
}
if (res.status !== 200) {
throw new Error(`login refused: ${typeof step.error === "string" ? step.error : res.status}`);
}
return step;
}
// A redirect would replay the proof to wherever it points
async function send(url, init) {
try {
return await fetch(url, { ...init, credentials: "same-origin", cache: "no-store", redirect: "error" });
} catch {
throw new Error(`cannot reach ${url.pathname}`);
}
}
function subtle() {
const s = globalThis.crypto?.subtle;
if (!s) throw new Error("WebCrypto unavailable: the page must be served over HTTPS");
return s;
}
async function hmac(key, message) {
const k = await subtle().importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
return new Uint8Array(await subtle().sign("HMAC", k, encoder.encode(message)));
}
function equal(a, b) {
if (a.length !== b.length) return false;
let d = 0;
for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i];
return d === 0;
}
function base64(bytes) {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
// Strict like Go's base64.StdEncoding.Strict(): the round trip rejects stray padding bits
function decodeBase64(s, maxBytes) {
if (typeof s !== "string" || s.length > 4 * Math.ceil(maxBytes / 3) ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(s)) {
return null;
}
const out = Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
return out.length <= maxBytes && base64(out) === s ? out : null;
}
// --- Argon2id (RFC 9106, version 0x13) ---
// Uint64s are (lo, hi) pairs of uint32 words; a 1 KiB block is 256 words.
const BLAKE2B_IV = new Uint32Array([
0xf3bcc908, 0x6a09e667, 0x84caa73b, 0xbb67ae85, 0xfe94f82b, 0x3c6ef372, 0x5f1d36f1, 0xa54ff53a,
0xade682d1, 0x510e527f, 0x2b3e6c1f, 0x9b05688c, 0xfb41bd6b, 0x1f83d9ab, 0x137e2179, 0x5be0cd19,
]);
const SIGMA = [
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
[14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3],
[11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4],
[7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8],
[9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13],
[2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9],
[12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11],
[13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10],
[6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5],
[10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0],
].map((row) => row.map((i) => 2 * i));
/**
* Argon2id over a password (string or bytes) and salt, as Uint8Array of keyLen
* bytes. Yields to the event loop every ~50 ms, so a page stays responsive.
*/
export async function argon2id(password, salt, time, memoryKiB, threads, keyLen,
{ secret = new Uint8Array(0), data = new Uint8Array(0), onProgress } = {}) {
const pw = typeof password === "string" ? encoder.encode(password) : password;
if (![time, memoryKiB, threads, keyLen].every(Number.isSafeInteger) || time < 1 || threads < 1 ||
threads > 0xffffff || memoryKiB < 8 * threads || memoryKiB > 0xffffffff || keyLen < 4 || keyLen > 0xffffffff) {
throw new Error("invalid Argon2 parameters");
}
const h0 = new Uint8Array(72);
h0.set(blake2b(concat(le32(threads), le32(keyLen), le32(memoryKiB), le32(time), le32(0x13), le32(2),
le32(pw.length), pw, le32(salt.length), salt, le32(secret.length), secret, le32(data.length), data), 64));
const laneLen = Math.floor(memoryKiB / (4 * threads)) * 4;
const st = {
mem: new Uint32Array(laneLen * threads * 256), lanes: threads, laneLen, segLen: laneLen / 4,
blocks: laneLen * threads, time, r: new Uint32Array(256), q: new Uint32Array(256),
input: new Uint32Array(256), addr: new Uint32Array(256),
};
for (let lane = 0; lane < threads; lane++) {
h0.set(le32(lane), 68);
for (let i = 0; i < 2; i++) {
h0.set(le32(i), 64);
const block = hprime(h0, 1024);
for (let w = 0; w < 256; w++) st.mem[(lane * laneLen + i) * 256 + w] = readLE32(block, 4 * w);
}
}
let done = 0, yielded = Date.now();
for (let pass = 0; pass < time; pass++) {
for (let slice = 0; slice < 4; slice++) {
for (let lane = 0; lane < threads; lane++) {
fillSegment(st, pass, slice, lane);
done++;
if (Date.now() - yielded > 50) {
onProgress?.(done / (time * 4 * threads));
await new Promise((resolve) => setTimeout(resolve, 0));
yielded = Date.now();
}
}
}
}
const last = st.mem.slice((laneLen - 1) * 256, laneLen * 256);
for (let lane = 1; lane < threads; lane++) {
const o = (lane * laneLen + laneLen - 1) * 256;
for (let w = 0; w < 256; w++) last[w] ^= st.mem[o + w];
}
const bytes = new Uint8Array(1024);
for (let w = 0; w < 256; w++) writeLE32(bytes, 4 * w, last[w]);
return hprime(bytes, keyLen);
}
function fillSegment(st, pass, slice, lane) {
const { mem, lanes, laneLen, segLen, r, q, input, addr } = st;
const independent = pass === 0 && slice < 2;
let index = pass === 0 && slice === 0 ? 2 : 0;
if (independent) {
input.fill(0);
input[0] = pass;
input[2] = lane;
input[4] = slice;
input[6] = st.blocks;
input[8] = st.time;
input[10] = 2;
if (index !== 0) nextAddresses(input, addr, q);
}
for (let cur = lane * laneLen + slice * segLen + index; index < segLen; index++, cur++) {
const prev = index === 0 && slice === 0 ? cur + laneLen - 1 : cur - 1;
let j1, j2;
if (independent) {
if (index % 128 === 0) nextAddresses(input, addr, q);
j1 = addr[2 * (index % 128)];
j2 = addr[2 * (index % 128) + 1];
} else {
j1 = mem[prev * 256];
j2 = mem[prev * 256 + 1];
}
const refLane = pass === 0 && slice === 0 ? lane : j2 % lanes;
const same = refLane === lane;
let area, start = 0;
if (pass === 0) {
area = slice * segLen + (slice === 0 || same ? index : 0);
} else {
area = 3 * segLen + (same ? index : 0);
start = ((slice + 1) % 4) * segLen;
}
if (index === 0 || same) area--;
const ref = refLane * laneLen + (start + area - 1 - mulHi(mulHi(j1, j1), area)) % laneLen;
const c = cur * 256, x = prev * 256, y = ref * 256;
for (let i = 0; i < 256; i++) r[i] = mem[x + i] ^ mem[y + i];
q.set(r);
permute(q);
for (let i = 0; i < 256; i++) mem[c + i] ^= r[i] ^ q[i];
}
}
// Data-independent addressing: two compressions of the counter block against zero
function nextAddresses(input, addr, q) {
input[12]++;
for (const src of [input, addr]) {
q.set(src);
permute(q);
for (let i = 0; i < 256; i++) addr[i] = src[i] ^ q[i];
}
}
function permute(v) {
for (let x = 0; x < 256; x += 32) {
blamka(v, x, x + 8, x + 16, x + 24);
blamka(v, x + 2, x + 10, x + 18, x + 26);
blamka(v, x + 4, x + 12, x + 20, x + 28);
blamka(v, x + 6, x + 14, x + 22, x + 30);
blamka(v, x, x + 10, x + 20, x + 30);
blamka(v, x + 2, x + 12, x + 22, x + 24);
blamka(v, x + 4, x + 14, x + 16, x + 26);
blamka(v, x + 6, x + 8, x + 18, x + 28);
}
for (let w = 0; w < 32; w += 4) {
blamka(v, w, w + 64, w + 128, w + 192);
blamka(v, w + 2, w + 66, w + 130, w + 194);
blamka(v, w + 32, w + 96, w + 160, w + 224);
blamka(v, w + 34, w + 98, w + 162, w + 226);
blamka(v, w, w + 66, w + 160, w + 226);
blamka(v, w + 2, w + 96, w + 162, w + 192);
blamka(v, w + 32, w + 98, w + 128, w + 194);
blamka(v, w + 34, w + 64, w + 130, w + 224);
}
}
// G of Argon2's P on four uint64s, in locals; the products are split so
// every intermediate stays exact in a double
function blamka(v, a, b, c, d) {
let al = v[a], ah = v[a + 1], bl = v[b], bh = v[b + 1];
let cl = v[c], ch = v[c + 1], dl = v[d], dh = v[d + 1];
let pl, ph, s, xl, xh;
pl = Math.imul(al, bl); ph = mulHi(al, bl);
s = al + bl + ((pl << 1) >>> 0);
ah = (ah + bh + ((ph << 1) | (pl >>> 31)) + Math.floor(s / 4294967296)) >>> 0; al = s >>> 0;
xl = dl ^ al; dl = (dh ^ ah) >>> 0; dh = xl >>> 0;
pl = Math.imul(cl, dl); ph = mulHi(cl, dl);
s = cl + dl + ((pl << 1) >>> 0);
ch = (ch + dh + ((ph << 1) | (pl >>> 31)) + Math.floor(s / 4294967296)) >>> 0; cl = s >>> 0;
xl = bl ^ cl; xh = bh ^ ch; bl = ((xl >>> 24) | (xh << 8)) >>> 0; bh = ((xh >>> 24) | (xl << 8)) >>> 0;
pl = Math.imul(al, bl); ph = mulHi(al, bl);
s = al + bl + ((pl << 1) >>> 0);
ah = (ah + bh + ((ph << 1) | (pl >>> 31)) + Math.floor(s / 4294967296)) >>> 0; al = s >>> 0;
xl = dl ^ al; xh = dh ^ ah; dl = ((xl >>> 16) | (xh << 16)) >>> 0; dh = ((xh >>> 16) | (xl << 16)) >>> 0;
pl = Math.imul(cl, dl); ph = mulHi(cl, dl);
s = cl + dl + ((pl << 1) >>> 0);
ch = (ch + dh + ((ph << 1) | (pl >>> 31)) + Math.floor(s / 4294967296)) >>> 0; cl = s >>> 0;
xl = bl ^ cl; xh = bh ^ ch; bl = (xl << 1) | (xh >>> 31); bh = (xh << 1) | (xl >>> 31);
v[a] = al; v[a + 1] = ah; v[b] = bl; v[b + 1] = bh;
v[c] = cl; v[c + 1] = ch; v[d] = dl; v[d + 1] = dh;
}
// High word of the 64-bit product of two uint32s
function mulHi(a, b) {
const a0 = a & 0xffff, a1 = a >>> 16, b0 = b & 0xffff, b1 = b >>> 16;
const m01 = a0 * b1, m10 = a1 * b0;
const carry = ((a0 * b0) >>> 16) + (m01 & 0xffff) + (m10 & 0xffff);
return a1 * b1 + (m01 >>> 16) + (m10 >>> 16) + (carry >>> 16);
}
function xorRotr(v, d, s, n) {
const lo = v[d] ^ v[s], hi = v[d + 1] ^ v[s + 1];
if (n === 32) {
v[d] = hi;
v[d + 1] = lo;
} else if (n === 63) {
v[d] = (lo << 1) | (hi >>> 31);
v[d + 1] = (hi << 1) | (lo >>> 31);
} else {
v[d] = (lo >>> n) | (hi << (32 - n));
v[d + 1] = (hi >>> n) | (lo << (32 - n));
}
}
// H' of RFC 9106: BLAKE2b stretched to any length
function hprime(input, outLen) {
const x = concat(le32(outLen), input);
if (outLen <= 64) return blake2b(x, outLen);
const out = new Uint8Array(outLen);
let v = blake2b(x, 64), pos = 32;
out.set(v.subarray(0, 32));
for (; outLen - pos > 64; pos += 32) {
v = blake2b(v, 64);
out.set(v.subarray(0, 32), pos);
}
out.set(blake2b(v, outLen - pos), pos);
return out;
}
// Unkeyed BLAKE2b (RFC 7693) of a short input
function blake2b(input, outLen) {
const h = BLAKE2B_IV.slice();
h[0] ^= 0x01010000 ^ outLen;
const v = new Uint32Array(32), m = new Uint32Array(32), block = new Uint8Array(128);
const blocks = Math.max(1, Math.ceil(input.length / 128));
for (let b = 0; b < blocks; b++) {
const last = b === blocks - 1;
block.fill(0);
block.set(input.subarray(b * 128, b * 128 + 128));
for (let w = 0; w < 32; w++) m[w] = readLE32(block, 4 * w);
const count = last ? input.length : (b + 1) * 128;
v.set(h);
v.set(BLAKE2B_IV, 16);
v[24] ^= count;
v[25] ^= count / 4294967296;
if (last) {
v[28] = ~v[28];
v[29] = ~v[29];
}
for (let round = 0; round < 12; round++) {
const s = SIGMA[round % 10];
b2mix(v, m, 0, 8, 16, 24, s[0], s[1]);
b2mix(v, m, 2, 10, 18, 26, s[2], s[3]);
b2mix(v, m, 4, 12, 20, 28, s[4], s[5]);
b2mix(v, m, 6, 14, 22, 30, s[6], s[7]);
b2mix(v, m, 0, 10, 20, 30, s[8], s[9]);
b2mix(v, m, 2, 12, 22, 24, s[10], s[11]);
b2mix(v, m, 4, 14, 16, 26, s[12], s[13]);
b2mix(v, m, 6, 8, 18, 28, s[14], s[15]);
}
for (let w = 0; w < 16; w++) h[w] ^= v[w] ^ v[w + 16];
}
const out = new Uint8Array(64);
for (let w = 0; w < 16; w++) writeLE32(out, 4 * w, h[w]);
return out.slice(0, outLen);
}
function b2mix(v, m, a, b, c, d, x, y) {
add64(v, a, v, b);
add64(v, a, m, x);
xorRotr(v, d, a, 32);
add64(v, c, v, d);
xorRotr(v, b, c, 24);
add64(v, a, v, b);
add64(v, a, m, y);
xorRotr(v, d, a, 16);
add64(v, c, v, d);
xorRotr(v, b, c, 63);
}
function add64(v, x, w, y) {
const lo = v[x] + w[y];
v[x + 1] = v[x + 1] + w[y + 1] + (lo > 0xffffffff ? 1 : 0);
v[x] = lo;
}
function concat(...parts) {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
}
function le32(n) {
const b = new Uint8Array(4);
writeLE32(b, 0, n);
return b;
}
function readLE32(b, i) {
return (b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24)) >>> 0;
}
function writeLE32(b, i, n) {
b[i] = n;
b[i + 1] = n >>> 8;
b[i + 2] = n >>> 16;
b[i + 3] = n >>> 24;
}
+44
View File
@@ -0,0 +1,44 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { argon2id, _proofForTest } from "./scram.js";
const hex = (b) => Buffer.from(b).toString("hex");
const unhex = (s) => new Uint8Array(Buffer.from(s, "hex"));
test("Argon2id matches RFC 9106 section 5.3", async () => {
const tag = await argon2id(new Uint8Array(32).fill(1), new Uint8Array(16).fill(2), 3, 32, 4, 32,
{ secret: new Uint8Array(8).fill(3), data: new Uint8Array(12).fill(4) });
assert.equal(hex(tag), "0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659");
});
// From golang.org/x/crypto/argon2.IDKey; m=1020 and m=37 exercise lane rounding,
// keyLen 100 the long form of H'.
test("Argon2id matches Go's argon2.IDKey", async () => {
const vectors = [
{ pw: "70617373776f7264", salt: "736f6d6573616c74", t: 1, m: 8, p: 1, keyLen: 32, want: "f137f8e186a403a679ccd0606e5ab5dcdafe43c1640855ac8c6e33e9bd63eeb3" },
{ pw: "", salt: "01080f161d242b323940474e555c636a", t: 2, m: 64, p: 2, keyLen: 32, want: "044879f042c4211727f27afa4e8fd18f95c199373cd03402f1f3dc4f07622d28" },
{ pw: "030a11181f262d343b424950575e656c737a81888f969da4abb2b9c0c7ced5dce3eaf1f8ff060d141b222930373e454c535a61686f767d848b9299a0a7aeb5bcc3cad1d8dfe6edf4fb020910171e252c333a41484f565d646b727980878e959ca3aab1b8", salt: "050c131a21282f363d444b525960676e757c838a91989fa6adb4bbc2c9d0d7de", t: 3, m: 256, p: 4, keyLen: 32, want: "a71f8b4acd16b613a2706aafb7ec23b1ddf1790b3aec1911f52152083dda79c9" },
{ pw: "70c3a4737377c3b6726420e29c93", salt: "0910171e252c333a41484f565d646b727980878e959ca3aab1b8bfc6cdd4dbe2e9f0f7fe050c131a21282f363d444b525960676e757c838a91989fa6adb4bbc2", t: 1, m: 1024, p: 3, keyLen: 32, want: "b069833fc8b42b81ad8c0baad6fa131f498fc2787a549809afe7459847029bc9" },
{ pw: "636f727265637420686f727365206261747465727920737461706c65", salt: "0b121920272e353c434a51585f666d74", t: 2, m: 4096, p: 1, keyLen: 32, want: "7d4729ce2bb0a9113eb50b1517b19172c47a61cf9b2c5c0bc6724997ecc52ad7" },
{ pw: "78", salt: "0d141b222930373e454c535a61686f767d848b92", t: 3, m: 37, p: 1, keyLen: 32, want: "f98a0cfc10228157b77cac1d45816f36447239e88bb5ed37e5a4f269f470607e" },
{ pw: "6c6f6e67206f7574707574", salt: "11181f262d343b424950575e656c737a", t: 1, m: 128, p: 2, keyLen: 100, want: "436511e64b4986f59fe6b2e86166a2cd2d543c2f221816780a7698284e0a81529fa25000a5da5fefc062ef70f0f8a94d87dbec1f292091e5fc14fdd7f155f29f4dd0073b604e3a7c5b77433e482337761a9c1dd454872e0e00cc22483b8b0513fbda54e4" },
];
for (const v of vectors) {
const key = await argon2id(unhex(v.pw), unhex(v.salt), v.t, v.m, v.p, v.keyLen);
assert.equal(hex(key), v.want, `t=${v.t} m=${v.m} p=${v.p} keyLen=${v.keyLen}`);
}
});
// lixenwraith/auth's unbound known answer; the server signature comes from the
// same AuthMessage under Go's DeriveCredential.
test("proof and server signature match lixenwraith/auth", async () => {
const challenge = {
full_nonce: "client-nonceserver-nonce", salt: Buffer.alloc(16, 7).toString("base64"),
argon_time: 1, argon_memory: 8, argon_threads: 1,
};
const got = await _proofForTest("alice", "password123", "client-nonce", challenge, 1, 8);
assert.deepEqual(got, {
client_proof: "JCVOdKAYwVu3YZx+6nfRfBUcnsTCgydOchnQq1G7Ry0=",
server_signature: "0HKxosIZvdoAzstS4dEkNgoYvcDpFnOgwBjyhiSsK00=",
});
});
+117
View File
@@ -0,0 +1,117 @@
:root {
color-scheme: light dark;
--bg: #f6f7f9;
--fg: #1d2330;
--muted: #5b6475;
--panel: #ffffff;
--line: #d6dae2;
--accent: #2f6fdb;
font: 15px/1.45 system-ui, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171d;
--fg: #e3e7ef;
--muted: #9aa3b5;
--panel: #1c2028;
--line: #2e3440;
--accent: #6ea0ff;
}
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
}
h1 {
margin: 0;
font-size: 1.25rem;
}
button {
font: inherit;
padding: 0.45rem 1rem;
border: 0;
border-radius: 6px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
.card {
max-width: 22rem;
margin: 12vh auto 0;
padding: 1.5rem;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
}
form {
display: grid;
gap: 0.9rem;
margin-top: 1.2rem;
}
label {
display: grid;
gap: 0.3rem;
color: var(--muted);
}
input {
font: inherit;
padding: 0.45rem 0.6rem;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--bg);
color: var(--fg);
}
progress {
width: 100%;
margin-top: 1rem;
}
#status {
min-height: 1.4em;
margin: 0.8rem 0 0;
color: var(--muted);
}
.viewer {
display: flex;
flex-direction: column;
height: 100vh;
}
.viewer header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.6rem 1rem;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
#state {
flex: 1;
color: var(--muted);
}
#log {
flex: 1;
overflow: auto;
padding: 0.5rem 1rem;
font: 13px/1.4 ui-monospace, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
+19
View File
@@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LogWisp stream</title>
<meta name="logwisp-status" content="status">
<link rel="stylesheet" href="style.css">
<script type="module" src="view.js"></script>
</head>
<body class="viewer">
<header>
<h1>LogWisp</h1>
<span id="state" role="status">connecting…</span>
<button id="logout" type="button">Sign out</button>
</header>
<main id="log"></main>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
import { logout } from "./scram.js";
const base = new URL("..", location.href);
// The server writes its status path here, relative to the mount
const statusPath = document.querySelector('meta[name="logwisp-status"]').content;
const MAX_LINES = 5000;
const log = document.getElementById("log");
const state = document.getElementById("state");
let source = null;
function append(line) {
const atBottom = log.scrollTop + log.clientHeight >= log.scrollHeight - 4;
const row = document.createElement("div");
row.textContent = line;
log.append(row);
while (log.childElementCount > MAX_LINES) log.firstElementChild.remove();
if (atBottom) log.scrollTop = log.scrollHeight;
}
// The status answer names the stream path; its 401 is the only way to tell an
// expired session apart, since EventSource hides the status of a failure.
async function connect() {
let res;
try {
res = await fetch(new URL("./" + statusPath, base), { credentials: "same-origin", cache: "no-store", redirect: "error" });
} catch {
return retry("server unreachable");
}
if (res.status === 401) {
location.replace(new URL("login?next=view", location.href).href);
return;
}
const path = res.ok ? (await res.json().catch(() => ({})))?.endpoints?.stream : null;
if (typeof path !== "string" || !path.startsWith("/")) {
return retry(`status unavailable (${res.status})`);
}
// Stream paths are absolute in logwisp; the proxy may mount it under a prefix
source = new EventSource(new URL("./" + path.replace(/^\/+/, ""), base));
source.addEventListener("connected", () => { state.textContent = "live"; });
source.addEventListener("disconnect", () => {
source.close();
retry("server shut down");
});
source.onmessage = (event) => event.data.split("\n").forEach(append);
source.onerror = () => {
if (source.readyState === EventSource.CLOSED) retry("disconnected");
else state.textContent = "reconnecting…";
};
}
function retry(reason) {
state.textContent = `${reason}, retrying…`;
setTimeout(connect, 5000);
}
document.getElementById("logout").addEventListener("click", async () => {
source?.close();
try {
await logout(base);
} catch (err) {
state.textContent = err.message;
return;
}
location.replace(new URL("login", location.href).href);
});
connect();
+17 -4
View File
@@ -55,8 +55,9 @@ type HTTPChainSink struct {
session *session.Session
config *config.HTTPChainSinkOptions
node string
url string
node string
baseURL string // scheme://host:port, where /auth lives
url string
tlsEnabled bool
mtls bool
@@ -106,6 +107,8 @@ func NewHTTPChainSinkPlugin(
opts.IngestPath = DefaultHTTPChainSinkIngestPath
} else if !strings.HasPrefix(opts.IngestPath, "/") {
return nil, fmt.Errorf("ingest_path: must start with '/'")
} else if opts.IngestPath == chain.AuthPath {
return nil, fmt.Errorf("ingest_path: %s is reserved for authentication", chain.AuthPath)
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultHTTPChainSinkBufferSize
@@ -142,7 +145,7 @@ func NewHTTPChainSinkPlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleDialer)
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleDialer, authz.HTTP)
if err != nil {
return nil, err
}
@@ -182,6 +185,7 @@ func NewHTTPChainSinkPlugin(
tlsEnabled: tlsCfg != nil,
mtls: tlsCfg != nil && len(tlsCfg.Certificates) > 0,
auth: authPolicy,
baseURL: scheme + "://" + addr,
url: scheme + "://" + addr + opts.IngestPath,
// A redirect would resend the batch, even from https to plaintext http
client: &http.Client{
@@ -214,6 +218,7 @@ func NewHTTPChainSinkPlugin(
"mtls", t.mtls,
"auth", authPolicy.Describe())
tlsx.LogWarnings(logger, "http_chain_sink", id, opts.TLS, false)
authPolicy.LogStartup(logger, "http_chain_sink", id, false)
return t, nil
}
@@ -435,10 +440,15 @@ func (t *HTTPChainSink) post(ctx context.Context, body []byte) (transient bool,
req.Header.Set("Content-Type", chain.ContentTypeNDJSON)
req.Header.Set(chain.HeaderProtocol, strconv.Itoa(chain.ProtocolVersion))
req.Header.Set(chain.HeaderNode, t.node)
// Future: Authorization header for auth
// Under scram: log in when no token is held; every failure is transient,
// so the batch waits under backoff rather than being dropped
if err := t.auth.Prepare(reqCtx, t.client, t.baseURL, req); err != nil {
return true, err
}
resp, err := t.client.Do(req)
if err != nil {
t.auth.Invalidate(0, err) // another server certificate: log in anew
return true, err
}
defer resp.Body.Close()
@@ -448,6 +458,9 @@ func (t *HTTPChainSink) post(ctx context.Context, body []byte) (transient bool,
switch {
case resp.StatusCode >= 200 && resp.StatusCode < 300:
return false, nil
case t.auth.Invalidate(resp.StatusCode, nil):
// Token expired or the source reloaded; the retry logs in again
return true, fmt.Errorf("status %s", resp.Status)
case resp.StatusCode == http.StatusRequestTimeout,
resp.StatusCode == http.StatusTooManyRequests,
resp.StatusCode >= 500:
+81
View File
@@ -1,16 +1,24 @@
package httpchain
import (
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"sync/atomic"
"testing"
"time"
"logwisp/internal/authz"
"logwisp/internal/chain"
"logwisp/internal/config"
"logwisp/internal/core"
"logwisp/internal/session"
"logwisp/internal/testutil"
"github.com/lixenwraith/auth"
"github.com/lixenwraith/log"
)
@@ -45,3 +53,76 @@ func TestRedirectIsNotFollowed(t *testing.T) {
t.Fatal("the batch followed the redirect")
}
}
// Under scram a 401 on ingest (an expired token, a reloaded source) logs in
// again, and the held batch is delivered exactly once.
func TestRefusedTokenLogsInAgainAndDeliversOnce(t *testing.T) {
pki := testutil.NewPKI(t, "edge-01")
dir := t.TempDir()
cred, err := auth.NewCredential("edge-01", "edge-01-secret")
if err != nil {
t.Fatal(err)
}
data, err := (&authz.Credentials{DecoyKey: make([]byte, 32), Users: []*auth.Credential{cred}}).Marshal()
if err != nil {
t.Fatal(err)
}
creds, password := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge-01.pass")
testutil.WriteFile(t, creds, string(data))
testutil.WriteFile(t, password, "edge-01-secret\n")
serverCert, err := tls.LoadX509KeyPair(pki.ServerCert, pki.ServerKey)
if err != nil {
t.Fatal(err)
}
serverTLS := &tls.Config{Certificates: []tls.Certificate{serverCert}}
listener, err := authz.New(&config.AuthOptions{Type: authz.MethodSCRAM, CredentialsFile: creds}, serverTLS, authz.RoleChainListener, authz.HTTP)
if err != nil {
t.Fatal(err)
}
if err := listener.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(listener.Close)
var refused, delivered atomic.Int64
mux := http.NewServeMux()
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) { listener.ServeAuth(w, r) })
mux.HandleFunc("POST /ingest", func(w http.ResponseWriter, r *http.Request) {
if _, status, err := listener.AuthorizeRequest(r); err != nil {
authz.Refuse(w, status)
return
}
if refused.Add(1) == 1 {
authz.Refuse(w, http.StatusUnauthorized) // as after a reload
return
}
delivered.Add(1)
w.WriteHeader(http.StatusNoContent)
})
srv := httptest.NewUnstartedServer(mux)
srv.TLS = serverTLS
srv.StartTLS()
t.Cleanup(srv.Close)
_, port, _ := net.SplitHostPort(srv.Listener.Addr().String())
p, _ := strconv.ParseInt(port, 10, 64)
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
created, err := NewHTTPChainSinkPlugin("fwd", map[string]any{
"host": "127.0.0.1", "port": p, "backoff_min_ms": int64(10),
"tls": map[string]any{"enabled": true, "ca_file": pki.CA},
"auth": map[string]any{"type": "scram", "username": "edge-01", "password_file": password},
}, log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
sink := created.(*HTTPChainSink)
sink.append(core.TransportEvent{Time: time.Now(), Payload: []byte("entry")})
if !sink.flush(t.Context()) || delivered.Load() != 1 || sink.droppedBatches.Load() != 0 {
t.Fatalf("delivered %d, dropped %d", delivered.Load(), sink.droppedBatches.Load())
}
if logins := listener.Stats()["auth_allowed"]; logins != uint64(2) {
t.Fatalf("logins = %v, want 2", logins)
}
}
+14 -4
View File
@@ -130,7 +130,7 @@ func NewTCPSinkPlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleListener)
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleListener, authz.TCP)
if err != nil {
return nil, err
}
@@ -205,8 +205,12 @@ func (t *TCPSink) listen() (net.Listener, error) {
// Start binds the listener and launches accept and broadcast loops
func (t *TCPSink) Start(ctx context.Context) error {
if err := t.auth.Start(); err != nil {
return err
}
ln, err := t.listen()
if err != nil {
t.auth.Close()
return fmt.Errorf("tcp sink bind %s: %w", t.addr, err)
}
t.listener = ln
@@ -241,6 +245,7 @@ func (t *TCPSink) Stop() {
t.shutdown()
t.wg.Wait()
t.auth.Close()
t.logger.Info("msg", " TCP sink stopped",
"component", "tcp_sink",
@@ -347,9 +352,13 @@ func (t *TCPSink) handleConn(conn net.Conn) {
}
}
// Authorize before registration, so an unauthorized peer never enters the
// client map and never receives a broadcast
ident, err := t.auth.Authorize(tlsState)
// Admit before registration, so an unauthorized peer never enters the
// client map and never receives a broadcast. Under scram the viewer's
// hello opens the exchange; without it the sink reads nothing.
adm, err := t.auth.Admit(conn, tlsState, false, authz.ExchangeTimeout)
if err == nil {
err = adm.Accept()
}
if err != nil {
t.rejectedConns.Add(1)
t.logger.Warn("msg", "Connection rejected by auth policy",
@@ -359,6 +368,7 @@ func (t *TCPSink) handleConn(conn net.Conn) {
"error", err)
return
}
ident := adm.Identity
ident.Apply(meta)
sess := t.proxy.CreateSession(remote, meta)
+58 -20
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"maps"
"net"
@@ -50,7 +51,6 @@ type TCPChainSink struct {
node string
addr string
helloLine []byte
tlsConfig *tls.Config
// Authorization: pins the downstream server's identity
@@ -59,8 +59,12 @@ type TCPChainSink struct {
input chan core.TransportEvent
logger *log.Logger
// conn owned exclusively by run loop goroutine
// conn and the backoff state are owned exclusively by the run loop goroutine.
// failures survives a link that dies right after connecting, so a refusing
// source is retried with growing delays rather than in a tight loop.
conn net.Conn
connectedAt time.Time
failures int
everConnected bool
dialTimeout time.Duration
writeTimeout time.Duration
@@ -125,15 +129,11 @@ func NewTCPChainSinkPlugin(
}
}
helloLine, err := chain.EncodeHello(node)
if err != nil {
return nil, fmt.Errorf("hello: %w", err)
}
tlsCfg, err := tlsx.Client(opts.TLS, opts.Host)
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleDialer)
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleDialer, authz.TCP)
if err != nil {
return nil, err
}
@@ -149,7 +149,6 @@ func NewTCPChainSinkPlugin(
config: opts,
node: node,
addr: net.JoinHostPort(opts.Host, strconv.FormatInt(opts.Port, 10)),
helloLine: helloLine,
tlsConfig: tlsCfg,
auth: authPolicy,
input: make(chan core.TransportEvent, opts.BufferSize),
@@ -179,6 +178,7 @@ func NewTCPChainSinkPlugin(
"mtls", tlsCfg != nil && len(tlsCfg.Certificates) > 0,
"auth", authPolicy.Describe())
tlsx.LogWarnings(logger, "tcp_chain_sink", id, opts.TLS, false)
authPolicy.LogStartup(logger, "tcp_chain_sink", id, false)
return t, nil
}
@@ -267,6 +267,17 @@ func (t *TCPChainSink) runLoop(ctx context.Context) {
defer t.wg.Done()
defer t.closeConn()
// Fold done into the context, so a connect or exchange in flight ends on Stop
ctx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
select {
case <-t.done:
cancel()
case <-ctx.Done():
}
}()
for {
select {
case <-ctx.Done():
@@ -302,22 +313,30 @@ func (t *TCPChainSink) runLoop(ctx context.Context) {
// deliver writes one line, holding it across reconnects until sent or shutdown.
// Backpressure during outage propagates to the pipeline dispatch drop counter.
func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool {
failures := 0
for {
if t.conn == nil {
if failures > 0 && !t.waitBackoff(ctx, failures) {
if t.failures > 0 && !t.waitBackoff(ctx, t.failures) {
return false
}
if err := t.connect(ctx); err != nil {
if ctx.Err() != nil {
return false
}
failures++
t.logger.Debug("msg", "Chain connect failed",
"component", "tcp_chain_sink",
"target", t.addr,
"attempt", failures,
"error", err)
t.failures++
if errors.Is(err, authz.ErrRefused) {
// A refusal is configuration, not weather: show it by default
t.logger.Warn("msg", "Chain connect refused",
"component", "tcp_chain_sink",
"target", t.addr,
"attempt", t.failures,
"error", err)
} else {
t.logger.Debug("msg", "Chain connect failed",
"component", "tcp_chain_sink",
"target", t.addr,
"attempt", t.failures,
"error", err)
}
continue
}
}
@@ -325,7 +344,7 @@ func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool {
t.conn.SetWriteDeadline(time.Now().Add(t.writeTimeout))
if _, err := t.conn.Write(line); err != nil {
t.writeErrors.Add(1)
failures++
t.failures++
t.logger.Warn("msg", "Chain write failed",
"component", "tcp_chain_sink",
"target", t.addr,
@@ -333,6 +352,10 @@ func (t *TCPChainSink) deliver(ctx context.Context, line []byte) bool {
t.closeConn()
continue
}
// Healthy once it outlives the shortest backoff
if t.failures > 0 && time.Since(t.connectedAt) >= time.Duration(t.config.BackoffMinMS)*time.Millisecond {
t.failures = 0
}
return true
}
}
@@ -363,11 +386,26 @@ func (t *TCPChainSink) connect(ctx context.Context) error {
return err
}
conn.SetWriteDeadline(time.Now().Add(t.writeTimeout))
if _, err := conn.Write(t.helloLine); err != nil {
r, err := t.auth.Greet(ctx, conn, t.node)
if err != nil {
conn.Close()
return fmt.Errorf("hello: %w", err)
return err
}
// A source never writes once a link is up: a line is a refusal this sink
// could not otherwise see, EOF a close. Stop writing into either.
t.wg.Add(1)
go func() {
defer t.wg.Done()
err := authz.AwaitClose(r)
conn.Close()
if errors.Is(err, authz.ErrRefused) {
t.logger.Warn("msg", "Chain link refused",
"component", "tcp_chain_sink",
"target", t.addr,
"error", err)
}
}()
t.connectedAt = time.Now()
t.conn = conn
t.connected.Store(true)
+68
View File
@@ -0,0 +1,68 @@
package tcpchain
import (
"bufio"
"net"
"strconv"
"sync/atomic"
"testing"
"time"
"logwisp/internal/core"
"logwisp/internal/session"
"github.com/lixenwraith/log"
)
// A source that refuses each link right after the hello, as a scram source
// does to a sink without credentials, is retried with growing delays, not in
// a tight loop that logs an established link every round.
func TestRefusingSourceIsRetriedWithBackoff(t *testing.T) {
ln, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ln.Close() })
var accepts atomic.Int64
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
accepts.Add(1)
bufio.NewReader(conn).ReadString('\n')
conn.Write([]byte(`{"error":"authentication required"}` + "\n"))
conn.Close()
}
}()
_, port, _ := net.SplitHostPort(ln.Addr().String())
p, _ := strconv.ParseInt(port, 10, 64)
manager := session.NewManager(time.Hour)
t.Cleanup(manager.Stop)
created, err := NewTCPChainSinkPlugin("fwd", map[string]any{
"host": "127.0.0.1", "port": p, "backoff_min_ms": int64(20), "backoff_max_ms": int64(5000),
}, log.NewLogger(), session.NewProxy(manager, "fwd"))
if err != nil {
t.Fatal(err)
}
sink := created.(*TCPChainSink)
if err := sink.Start(t.Context()); err != nil {
t.Fatal(err)
}
t.Cleanup(sink.Stop)
deadline := time.Now().Add(1500 * time.Millisecond)
for time.Now().Before(deadline) {
select {
case sink.Input() <- core.TransportEvent{Time: time.Now(), Payload: []byte("entry")}:
default:
}
time.Sleep(5 * time.Millisecond)
}
// 20 ms doubling reaches 1.5 s in about 7 attempts; a constant delay
// would take dozens
if n := accepts.Load(); n > 10 {
t.Fatalf("%d connections in 1.5 s against a refusing source", n)
}
}
+33 -6
View File
@@ -95,6 +95,8 @@ func NewHTTPChainSourcePlugin(
opts.IngestPath = DefaultHTTPChainSourceIngestPath
} else if !strings.HasPrefix(opts.IngestPath, "/") {
return nil, fmt.Errorf("ingest_path: must start with '/'")
} else if opts.IngestPath == chain.AuthPath {
return nil, fmt.Errorf("ingest_path: %s is reserved for authentication", chain.AuthPath)
}
if opts.BufferSize <= 0 {
opts.BufferSize = DefaultHTTPChainSourceBufferSize
@@ -109,7 +111,7 @@ func NewHTTPChainSourcePlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleChainListener)
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleChainListener, authz.HTTP)
if err != nil {
return nil, err
}
@@ -163,16 +165,22 @@ func (s *HTTPChainSource) Subscribe() <-chan core.LogEntry {
// Start binds the listener and serves the ingest endpoint
func (s *HTTPChainSource) Start() error {
if err := s.auth.Start(); err != nil {
return err
}
addr := net.JoinHostPort(s.config.Host, strconv.FormatInt(s.config.Port, 10))
// IPv4-only, aligns with tcp/http sinks
ln, err := net.Listen("tcp4", addr)
if err != nil {
s.auth.Close()
return fmt.Errorf("listen %s: %w", addr, err)
}
mux := http.NewServeMux()
// Method-scoped pattern: mux answers 405 with Allow header on non-POST
mux.HandleFunc(http.MethodPost+" "+s.config.IngestPath, s.handleIngest)
// Answers 404 unless the policy is scram
mux.HandleFunc(http.MethodPost+" "+chain.AuthPath, s.handleAuth)
s.server = &http.Server{
Handler: mux,
@@ -212,6 +220,7 @@ func (s *HTTPChainSource) Stop() {
defer cancel()
s.server.Shutdown(ctx)
}
s.auth.Close()
s.sessionsMu.Lock()
for _, id := range s.sessions {
@@ -269,10 +278,9 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
s.totalRequests.Add(1)
// Authorize before the body is read: an unauthorized sender should not get
// to stream max_body_bytes into the process. 403 is distinct from the 400
// used for protocol errors, so a sender can tell "not allowed" from
// "malformed batch".
ident, err := s.auth.Authorize(r.TLS)
// to stream max_body_bytes into the process. 401 (log in again) and 403
// (not allowed) are distinct from the 400 used for protocol errors.
ident, status, err := s.auth.AuthorizeRequest(r)
if err != nil {
s.rejectedRequests.Add(1)
s.logger.Warn("msg", "Request rejected by auth policy",
@@ -280,7 +288,7 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
"instance_id", s.id,
"remote_addr", r.RemoteAddr,
"error", err)
http.Error(w, "forbidden", http.StatusForbidden)
authz.Refuse(w, status)
return
}
@@ -354,6 +362,25 @@ func (s *HTTPChainSource) handleIngest(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// handleAuth runs one step of a SCRAM login and logs its outcome
func (s *HTTPChainSource) handleAuth(w http.ResponseWriter, r *http.Request) {
ident, err := s.auth.ServeAuth(w, r)
switch {
case err != nil:
s.logger.Warn("msg", "Login rejected",
"component", "http_chain_source",
"instance_id", s.id,
"remote_addr", r.RemoteAddr,
"error", err)
case ident.Name != "":
s.logger.Info("msg", "Login accepted",
"component", "http_chain_source",
"instance_id", s.id,
"remote_addr", r.RemoteAddr,
"auth_identity", ident.Name)
}
}
// sessionFor returns the cached session for a remote+node+identity,
// recreating after idle expiry. Identity is part of the key so two peers
// sharing a remote address never share a session.
+34 -34
View File
@@ -96,7 +96,7 @@ func NewTCPChainSourcePlugin(
if err != nil {
return nil, err
}
authPolicy, err := authz.New(opts.Auth, opts.TLS, authz.RoleChainListener)
authPolicy, err := authz.New(opts.Auth, tlsCfg, authz.RoleChainListener, authz.TCP)
if err != nil {
return nil, err
}
@@ -149,11 +149,15 @@ func (s *TCPChainSource) Subscribe() <-chan core.LogEntry {
// Start binds the listener and begins accepting connections
func (s *TCPChainSource) Start() error {
if err := s.auth.Start(); err != nil {
return err
}
addr := net.JoinHostPort(s.config.Host, strconv.FormatInt(s.config.Port, 10))
// IPv4-only. TLS-wrapped when configured; handshake runs explicitly in
// handleConn under tlsx.HandshakeTimeout, pre-hello.
ln, err := net.Listen("tcp4", addr)
if err != nil {
s.auth.Close()
return fmt.Errorf("listen %s: %w", addr, err)
}
if s.tlsConfig != nil {
@@ -189,6 +193,7 @@ func (s *TCPChainSource) Stop() {
s.mu.Unlock()
s.wg.Wait()
s.auth.Close()
s.mu.Lock()
for _, ch := range s.subscribers {
@@ -292,56 +297,51 @@ func (s *TCPChainSource) handleConn(conn net.Conn) {
tlsState = &cs
}
// Authorize before a preamble is parsed on an unauthorized peer's behalf
ident, err := s.auth.Authorize(tlsState)
// Certificates are checked before the hello is read; under scram the hello
// carries the exchange. Nothing is acknowledged until node binding agrees.
adm, err := s.auth.Admit(conn, tlsState, true, time.Duration(s.config.HelloTimeoutMS)*time.Millisecond)
if err != nil {
s.rejectedConns.Add(1)
s.logger.Warn("msg", "Connection rejected by auth policy",
"component", "tcp_chain_source",
"instance_id", s.id,
"remote_addr", remote,
"error", err)
return // deferred cleanup closes conn
}
scanner := bufio.NewScanner(conn)
// Oversized line (> MaxLogEntryBytes) is a protocol violation; scanner is
// unrecoverable after ErrTooLong, connection terminates
scanner.Buffer(make([]byte, 0, 64*1024), core.MaxLogEntryBytes)
// Hello preamble
conn.SetReadDeadline(time.Now().Add(time.Duration(s.config.HelloTimeoutMS) * time.Millisecond))
if !scanner.Scan() {
s.logger.Warn("msg", "Connection closed before hello",
"component", "tcp_chain_source",
"remote_addr", remote,
"error", scanner.Err())
return
}
hello, err := chain.DecodeHello(scanner.Bytes())
if err != nil {
s.logger.Warn("msg", "Rejected chain connection",
"component", "tcp_chain_source",
"remote_addr", remote,
"error", err)
if errors.Is(err, authz.ErrRefused) {
s.rejectedConns.Add(1)
s.logger.Warn("msg", "Connection rejected by auth policy",
"component", "tcp_chain_source",
"instance_id", s.id,
"remote_addr", remote,
"error", err)
} else {
s.logger.Warn("msg", "Rejected chain connection",
"component", "tcp_chain_source",
"remote_addr", remote,
"error", err)
}
return
}
ident := adm.Identity
fallbackNode := remote
if host, _, splitErr := net.SplitHostPort(remote); splitErr == nil {
fallbackNode = host
}
connNode, err := s.auth.ResolveNode(hello.Node, fallbackNode, s.config.TrustNode, ident)
connNode, err := s.auth.ResolveNode(adm.Hello.Node, fallbackNode, s.config.TrustNode, ident)
if err != nil {
s.rejectedConns.Add(1)
adm.Reject("node label rejected")
s.logger.Warn("msg", "Connection rejected by node binding",
"component", "tcp_chain_source",
"instance_id", s.id,
"remote_addr", remote,
"declared_node", hello.Node,
"declared_node", adm.Hello.Node,
"error", err)
return
}
if err := adm.Accept(); err != nil {
return
}
scanner := bufio.NewScanner(adm.Reader)
// Oversized line (> MaxLogEntryBytes) is a protocol violation; scanner is
// unrecoverable after ErrTooLong, connection terminates
scanner.Buffer(make([]byte, 0, 64*1024), core.MaxLogEntryBytes)
// force relabels every entry, so an edge cannot smuggle a foreign origin
// through the per-entry node field either
trustEntryNode := s.auth.TrustsEntryNode(s.config.TrustNode)
+40 -25
View File
@@ -44,12 +44,16 @@ func WriteFile(t testing.TB, path, contents string) {
// certificate, written as PEM files for plugins that load TLS from disk.
type PKI struct {
CA, ServerCert, ServerKey, ClientCert, ClientKey string
dir string
ca *x509.Certificate
caKey *ecdsa.PrivateKey
serial int64
}
// NewPKI issues the PKI into a test directory; clientCN names the client.
func NewPKI(t testing.TB, clientCN string) PKI {
func NewPKI(t testing.TB, clientCN string) *PKI {
t.Helper()
dir := t.TempDir()
now := time.Now()
caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
@@ -68,32 +72,43 @@ func NewPKI(t testing.TB, clientCN string) PKI {
if err != nil {
t.Fatal(err)
}
pki := PKI{CA: writePEM(t, dir, "ca.crt", "CERTIFICATE", caDER)}
issue := func(name, cn string, serial int64, usage x509.ExtKeyUsage, ips []net.IP) (string, string) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: cn},
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{usage}, IPAddresses: ips,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, ca, &key.PublicKey, caKey)
if err != nil {
t.Fatal(err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
return writePEM(t, dir, name+".crt", "CERTIFICATE", der), writePEM(t, dir, name+".key", "PRIVATE KEY", keyDER)
}
pki.ServerCert, pki.ServerKey = issue("server", "relay.internal", 2, x509.ExtKeyUsageServerAuth, []net.IP{net.IPv4(127, 0, 0, 1)})
pki.ClientCert, pki.ClientKey = issue("client", clientCN, 3, x509.ExtKeyUsageClientAuth, nil)
pki := &PKI{dir: t.TempDir(), ca: ca, caKey: caKey, serial: 1}
pki.CA = writePEM(t, pki.dir, "ca.crt", "CERTIFICATE", caDER)
pki.ServerCert, pki.ServerKey = pki.Leaf(t, "server", "relay.internal", false)
pki.ClientCert, pki.ClientKey = pki.Leaf(t, "client", clientCN, true)
return pki
}
// Leaf issues another certificate from the same CA: a client certificate, or
// a server certificate valid for 127.0.0.1.
func (p *PKI) Leaf(t testing.TB, name, cn string, client bool) (certFile, keyFile string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
p.serial++
now := time.Now()
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(p.serial), Subject: pkix.Name{CommonName: cn},
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
}
if client {
tmpl.ExtKeyUsage, tmpl.IPAddresses = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, nil
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, p.ca, &key.PublicKey, p.caKey)
if err != nil {
t.Fatal(err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatal(err)
}
return writePEM(t, p.dir, name+".crt", "CERTIFICATE", der), writePEM(t, p.dir, name+".key", "PRIVATE KEY", keyDER)
}
func writePEM(t testing.TB, dir, name, blockType string, der []byte) string {
t.Helper()
path := filepath.Join(dir, name)
+9 -1
View File
@@ -43,6 +43,14 @@ func (tb *TokenBucket) AllowN(n float64) bool {
return false
}
// Refund returns n tokens, never beyond capacity, for an attempt that turned
// out not to count against the limit
func (tb *TokenBucket) Refund(n float64) {
tb.mu.Lock()
defer tb.mu.Unlock()
tb.tokens = min(tb.capacity, tb.tokens+n)
}
// Tokens returns the current number of available tokens
func (tb *TokenBucket) Tokens() float64 {
tb.mu.Lock()
@@ -84,4 +92,4 @@ func (tb *TokenBucket) Capacity() float64 {
tb.mu.Lock()
defer tb.mu.Unlock()
return tb.capacity
}
}