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

+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)
}
}