775 lines
28 KiB
Go
775 lines
28 KiB
Go
package authz
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/lixenwraith/logwisp/internal/chain"
|
|
"github.com/lixenwraith/logwisp/internal/config"
|
|
"github.com/lixenwraith/logwisp/internal/netacl"
|
|
"github.com/lixenwraith/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 config.Side, transport Transport) *Policy {
|
|
t.Helper()
|
|
o.Type, o.CredentialsFile = MethodSCRAM, f.creds
|
|
p, err := New(&o, tlsCfg, nil, 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, nil, config.Dialer, 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, config.ChainListener, 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, config.Listener, 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, config.Listener, 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, config.ChainListener, 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, config.Listener, 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)
|
|
}
|
|
for i := range limitPending { // a pipe's peer has no address
|
|
if !l.listener.limit.Take(netacl.Key(netip.Addr{})) {
|
|
t.Fatalf("limiter still holds the abandoned exchange: slot %d taken", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, config.ChainListener, 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, config.Listener, 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, config.Listener, 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, config.Listener, 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, config.Listener, 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, config.Listener, 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, config.Listener, 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, config.Listener, 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())
|
|
}
|
|
}
|
|
|
|
// 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, config.Listener, 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, config.Listener, 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, config.Listener, 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, config.Listener, 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, config.Listener, 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, config.Listener, 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: 10000}, f.serverTLS, config.Listener, 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 > 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)
|
|
}
|
|
}
|
|
|
|
// A renewal put off (throttled, or the server busy) keeps the link sending on
|
|
// the token it still holds, and retries soon, but only to the pinned server;
|
|
// any other refusal, or the token's expiry, ends it.
|
|
func TestPutOffRenewalKeepsTheValidToken(t *testing.T) {
|
|
f := newFixture(t)
|
|
l := f.listener(t, config.AuthOptions{TokenLifetimeMS: 10000}, f.serverTLS, config.Listener, HTTP)
|
|
srv, _ := f.httpListener(t, l, f.serverTLS)
|
|
// An impostor with another certificate of the same CA
|
|
var answer, stolen atomic.Int64
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("POST "+chain.AuthPath, func(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, int(answer.Load()), authStep{Error: "no"})
|
|
})
|
|
mux.HandleFunc("GET /protected", func(w http.ResponseWriter, r *http.Request) { stolen.Add(1) })
|
|
imp := httptest.NewUnstartedServer(mux)
|
|
imp.TLS = f.serverTLS.Clone()
|
|
imp.TLS.Certificates = []tls.Certificate{f.serverLeaf(t, "impostor")}
|
|
imp.StartTLS()
|
|
t.Cleanup(imp.Close)
|
|
|
|
d := f.dialer(t, "edge-01", "edge-01-secret")
|
|
client := f.httpClient(d)
|
|
var reroute atomic.Pointer[string]
|
|
client.Transport.(*http.Transport).DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
if to := reroute.Load(); to != nil {
|
|
addr = *to
|
|
}
|
|
return (&net.Dialer{}).DialContext(ctx, network, addr)
|
|
}
|
|
prepare := func(renew bool) (*http.Request, error) {
|
|
if renew {
|
|
d.dialer.renewAt.Store(time.Now().UnixNano())
|
|
}
|
|
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/protected", nil)
|
|
return req, d.Prepare(t.Context(), client, srv.URL, req)
|
|
}
|
|
held := func() string {
|
|
req, err := prepare(false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return req.Header.Get("Authorization")
|
|
}
|
|
|
|
first := held()
|
|
client.CloseIdleConnections()
|
|
to := imp.Listener.Addr().String()
|
|
reroute.Store(&to)
|
|
answer.Store(http.StatusTooManyRequests)
|
|
req, err := prepare(true)
|
|
if err != nil || req.Header.Get("Authorization") != first {
|
|
t.Fatalf("put-off renewal: header %q, %v; want the held token", req.Header.Get("Authorization"), err)
|
|
}
|
|
if ahead := time.Until(time.Unix(0, d.dialer.renewAt.Load())); ahead <= 0 || ahead > 5*time.Second {
|
|
t.Fatalf("next renewal %v ahead, want within 5 s", ahead)
|
|
}
|
|
if _, err := client.Do(req); !errors.Is(err, errPinMismatch) || stolen.Load() != 0 {
|
|
t.Fatalf("the held token went to a server the pin never checked: %v, %d requests", err, stolen.Load())
|
|
}
|
|
answer.Store(http.StatusUnauthorized)
|
|
if _, err := prepare(true); err == nil || d.dialer.token.Load() != nil {
|
|
t.Fatalf("a refused renewal kept the token: %v", err)
|
|
}
|
|
|
|
client.CloseIdleConnections()
|
|
reroute.Store(nil)
|
|
first = held()
|
|
l.Close() // every further login answers busy
|
|
if req, err := prepare(true); err != nil || req.Header.Get("Authorization") != first {
|
|
t.Fatalf("busy renewal: %v; want the held token", err)
|
|
}
|
|
d.dialer.expires.Store(time.Now().UnixNano())
|
|
if _, err := prepare(true); err == nil {
|
|
t.Fatal("an expired token was kept after a put-off renewal")
|
|
}
|
|
}
|