v0.31.0 refinements

This commit is contained in:
lixen committed 2026-10-10 12:01:54 -04:00
1 parent bfcc15613a
commit 6bd5e12a60
52 files changed
+1948 -683

No files matched your search

+1 -1
View File
@@ -448,7 +448,7 @@ func (p *Policy) Enabled() bool { return p != nil }
func (p *Policy) NeedsLogin() bool { return p != nil && p.listener != nil }
// Unrestricted reports whether an mtls policy admits any identity the CA
// vouches for. Under scram the credentials file is the allow list.
// vouches for. Under scram the users are the allow list.
func (p *Policy) Unrestricted() bool {
return p != nil && p.method == MethodMTLS && len(p.allow) == 0 && len(p.patterns) == 0
}
+8 -5
View File
@@ -139,9 +139,10 @@ func TestNewValidation(t *testing.T) {
{"dialer skips verify", &config.AuthOptions{Type: MethodMTLS}, &tls.Config{InsecureSkipVerify: true}, config.Dialer, TCP, "insecure_skip_verify"},
{"mtls with a password", &config.AuthOptions{Type: MethodMTLS, PasswordFile: pw}, &tls.Config{}, config.Dialer, TCP, "password_file applies only"},
{"scram without tls", listener(config.AuthOptions{}), nil, config.Listener, TCP, "requires tls.enabled"},
{"scram without credentials", scram(config.AuthOptions{}), f.serverTLS, config.Listener, TCP, "requires credentials_file"},
{"scram with an allow list", listener(config.AuthOptions{Allow: []string{"edge-01"}}), f.serverTLS, config.Listener, TCP, "credentials file is the allow list"},
{"scram listener with a password", listener(config.AuthOptions{Username: "edge-01"}), f.serverTLS, config.Listener, TCP, "only to dialers"},
{"scram without users", scram(config.AuthOptions{}), f.serverTLS, config.Listener, TCP, "requires credentials_file, or username and password_file"},
{"scram with an allow list", listener(config.AuthOptions{Allow: []string{"edge-01"}}), f.serverTLS, config.Listener, TCP, "the users are the allow list"},
{"credentials and a user", listener(config.AuthOptions{Username: "edge-01"}), f.serverTLS, config.Listener, TCP, "not both"},
{"user without password_file", scram(config.AuthOptions{Username: "edge-01"}), f.serverTLS, config.Listener, TCP, "go together"},
{"scram certificate binding without client_auth", listener(config.AuthOptions{Identity: "cn"}), f.serverTLS, config.Listener, TCP, "requires tls.client_auth"},
{"token lifetime on tcp", listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, config.Listener, TCP, "only to HTTP listeners"},
{"token lifetime too short", listener(config.AuthOptions{TokenLifetimeMS: 900}), f.serverTLS, config.Listener, HTTP, "must be from 10000 to 86400000"},
@@ -176,8 +177,10 @@ func TestNewValidation(t *testing.T) {
}
}
}
if _, err := New(listener(config.AuthOptions{TokenLifetimeMS: 60000}), f.serverTLS, nil, config.Listener, HTTP); err != nil {
t.Fatalf("scram listener rejected: %v", err)
for _, o := range []*config.AuthOptions{listener(config.AuthOptions{TokenLifetimeMS: 60000}), scram(config.AuthOptions{Username: "edge-01", PasswordFile: pw})} {
if _, err := New(o, f.serverTLS, nil, config.Listener, 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, nil, config.Listener, HTTP); err != nil {
+159 -7
View File
@@ -16,8 +16,11 @@ import (
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/lixenwraith/logwisp/internal/chain"
@@ -27,6 +30,7 @@ import (
"github.com/lixenwraith/auth"
"github.com/lixenwraith/toml"
"golang.org/x/term"
)
const (
@@ -162,10 +166,87 @@ func (c *Credentials) Marshal() ([]byte, error) {
return append([]byte(header), body...), nil
}
// ReadPassword reads a password file, trimming one trailing line break so a
// file from an editor and one from `lw auth add-user` agree.
func ReadPassword(path string) (string, error) {
data, err := os.ReadFile(path)
// processDecoy is the decoy key of listeners without a credentials file:
// one per process, so reloads keep unknown-user challenges stable
var processDecoy = sync.OnceValue(func() []byte { key := make([]byte, 32); rand.Read(key); return key })
// oneUser is a listener's only user, derived in memory at the dialers' floor
// cost, the default outside tests
func oneUser(user, path string) (*Credentials, error) {
pw, err := ReadPassword(path, user, true)
if err != nil {
return nil, err
}
cred, err := auth.NewCredential(user, pw, auth.WithTime(minArgonTime), auth.WithMemory(minArgonMemory))
if err != nil {
return nil, fmt.Errorf("auth: user %q: %w", user, err)
}
return &Credentials{DecoyKey: processDecoy(), Users: []*auth.Credential{cred}}, nil
}
// readOnce keeps what a reload cannot read again, by path and user, and
// whether a terminal was asked: one may be asked for several users
var readOnce = struct {
sync.Mutex
m map[[2]string]asked
}{m: map[[2]string]asked{}}
type asked struct {
pw string
tty bool
}
// startupOver is set once lw's first service is built
var startupOver atomic.Bool
// EndStartup stops reading descriptors, pipes, FIFOs and terminals: at a
// reload a console source may be reading the same one, and a read would hold
// the reload and its signals. What was read before stays for reloads.
func EndStartup() { startupOver.Store(true) }
// ReadPassword reads user's password from path, trimming one trailing line
// break so a file from an editor and one from `lw auth add-user` agree. A
// regular file is read each time, for rotation by reload; /dev/fd/N, a pipe,
// a FIFO or another device once, before EndStartup; a terminal is asked once,
// where generate lets Enter alone draw the password.
func ReadPassword(path, user string, generate bool) (string, error) {
fd, inherited := descriptor(path)
if fi, err := os.Stat(path); !inherited && (err != nil || fi.Mode().IsRegular()) {
data, err := os.ReadFile(path)
return checkPassword(path, data, err)
}
readOnce.Lock()
defer readOnce.Unlock()
key := [2]string{path, user}
if a, ok := readOnce.m[key]; ok {
return a.pw, nil
}
if startupOver.Load() {
return "", fmt.Errorf("auth: password_file %s is read only at startup; name a regular file or restart lw", path)
}
for k, a := range readOnce.m {
if k[0] == path && !a.tty {
return "", fmt.Errorf("auth: password_file %s was read for user %q and holds one password", path, k[1])
}
}
var (
data []byte
tty bool
err error
)
if inherited {
data, err = readDescriptor(fd, path)
} else {
data, tty, err = readDevice(path, user, generate)
}
pw, err := checkPassword(path, data, err)
if err == nil {
readOnce.m[key] = asked{pw, tty}
}
return pw, err
}
func checkPassword(path string, data []byte, err error) (string, error) {
if err != nil {
return "", fmt.Errorf("auth: password_file: %w", err)
}
@@ -176,6 +257,70 @@ func ReadPassword(path string) (string, error) {
return pw, nil
}
// descriptor is N of /dev/fd/N, which ReadPassword closes: the path then
// names another file. 0-2 stay open, or stdio would go to the next file.
func descriptor(path string) (int, bool) {
n, ok := strings.CutPrefix(path, "/dev/fd/")
fd, err := strconv.Atoi(n)
return fd, ok && err == nil && fd > 2
}
// readDescriptor reads an inherited descriptor to its end and closes it,
// which needs no fdescfs on FreeBSD. Go opens its own close-on-exec, so a
// descriptor with the flag is lw's: reading or closing it would break lw.
func readDescriptor(fd int, path string) ([]byte, error) {
flags, _, errno := syscall.Syscall(syscall.SYS_FCNTL, uintptr(fd), syscall.F_GETFD, 0)
if errno != 0 || flags&syscall.FD_CLOEXEC != 0 {
return nil, fmt.Errorf("%s was not passed to lw: redirect it, as in %d<FILE", path, fd)
}
f := os.NewFile(uintptr(fd), path)
defer f.Close()
return io.ReadAll(io.LimitReader(f, auth.MaxPasswordLen+3))
}
// readDevice reads a pipe, FIFO or device to its end, or asks when it is a
// terminal. The open file decides: on FreeBSD stat calls /dev/stdin a device
// whatever it holds.
func readDevice(path, user string, generate bool) ([]byte, bool, error) {
f, err := os.Open(path)
if errors.Is(err, syscall.ENXIO) {
err = fmt.Errorf("%s: no terminal to ask on; name a password file", path)
}
if err != nil {
return nil, false, err
}
defer f.Close()
if term.IsTerminal(int(f.Fd())) {
data, err := ask(path, user, generate)
return data, true, err
}
data, err := io.ReadAll(io.LimitReader(f, auth.MaxPasswordLen+3))
return data, false, err
}
// ask prompts on the terminal at path without echo. A generated password is
// shown there only: logs persist, and stderr is often a file.
func ask(path, user string, generate bool) ([]byte, error) {
tty, err := os.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return nil, err
}
defer tty.Close()
hint := ""
if generate {
hint = " (Enter generates one)"
}
fmt.Fprintf(tty, "password for %s%s: ", user, hint)
data, err := term.ReadPassword(int(tty.Fd()))
fmt.Fprintln(tty)
if err == nil && len(data) == 0 && generate {
pw := rand.Text()
fmt.Fprintf(tty, "generated password for %s, shown once: %s\n", user, pw)
return []byte(pw), nil
}
return data, err
}
// --- Listener ---
type scramListener struct {
@@ -215,7 +360,14 @@ func (p *Policy) compileSCRAM(o *config.AuthOptions, tlsCfg *tls.Config) error {
cb := sha256.Sum256(tlsCfg.Certificates[0].Certificate[0])
l.cb = cb[:]
}
if l.creds, err = LoadCredentials(o.CredentialsFile); err != nil {
secret := secretFile{"auth.credentials_file", o.CredentialsFile}
if o.CredentialsFile != "" {
l.creds, err = LoadCredentials(o.CredentialsFile)
} else {
secret = secretFile{"auth.password_file", o.PasswordFile}
l.creds, err = oneUser(o.Username, o.PasswordFile)
}
if err != nil {
return err
}
if p.transport == HTTP {
@@ -231,7 +383,7 @@ func (p *Policy) compileSCRAM(o *config.AuthOptions, tlsCfg *tls.Config) error {
}
}
p.listener = l
p.secrets = append(p.secrets, secretFile{"auth.credentials_file", o.CredentialsFile})
p.secrets = append(p.secrets, secret)
return nil
}
@@ -543,7 +695,7 @@ type scramDialer struct {
}
func (p *Policy) compileSCRAMDialer(o *config.AuthOptions) error {
password, err := ReadPassword(o.PasswordFile)
password, err := ReadPassword(o.PasswordFile, o.Username, false)
if err != nil {
return err
}
+127 -15
View File
@@ -19,6 +19,7 @@ import (
"regexp"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
@@ -101,7 +102,9 @@ func (f *fixture) pool(t *testing.T) *x509.CertPool {
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
if o.Type = MethodSCRAM; o.Username == "" {
o.CredentialsFile = f.creds
}
p, err := New(&o, tlsCfg, nil, role, transport)
if err != nil {
t.Fatal(err)
@@ -348,21 +351,24 @@ func TestCertificateBindsToUser(t *testing.T) {
}
}
// 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.
// An unknown user's challenge salt comes from the decoy key, the file's or,
// for one user without a file, the process's, so it is the same after a
// rebuild and a prober learns nothing from restarts or reloads.
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)
for _, o := range []config.AuthOptions{{}, {Username: "pipe", PasswordFile: f.write(t, "pipe.pass", "pipe-secret\n")}} {
var salts []string
for range 2 {
l := f.listener(t, o, 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("%+v: unknown user salts differ across rebuilds: %q, %q", o, salts[0], salts[1])
}
salts = append(salts, c.Salt)
}
if salts[0] != salts[1] {
t.Fatalf("unknown user salts differ across restarts: %q, %q", salts[0], salts[1])
}
}
@@ -615,15 +621,121 @@ func TestRefusedHellosFreeTheirSlot(t *testing.T) {
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" {
if pw, err := ReadPassword(f.write(t, "pw", contents), "edge-01", false); err != nil || pw != "secret" {
t.Errorf("ReadPassword(%q) = %q, %v", contents, pw, err)
}
}
if _, err := ReadPassword(f.write(t, "pw", "\n")); err == nil {
if _, err := ReadPassword(f.write(t, "pw", "\n"), "edge-01", false); err == nil {
t.Error("an empty password was accepted")
}
}
// A descriptor's password is read once and the descriptor closed: a reload
// gets it from memory, and another user naming it is refused.
func TestPasswordFromADescriptorSurvivesReload(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
fd, err := syscall.Dup(int(r.Fd())) // not close-on-exec, as a shell passes it
r.Close()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { readOnce.Lock(); clear(readOnce.m); readOnce.Unlock() })
var before, after syscall.Stat_t
syscall.Fstat(fd, &before)
w.WriteString("pipe-secret\n")
w.Close()
path := fmt.Sprintf("/dev/fd/%d", fd)
for range 2 {
if pw, err := ReadPassword(path, "pipe", true); err != nil || pw != "pipe-secret" {
t.Fatalf("ReadPassword(%s) = %q, %v", path, pw, err)
}
}
if syscall.Fstat(fd, &after) == nil && after.Ino == before.Ino {
t.Error("the descriptor was left open")
}
if _, err := ReadPassword(path, "other", false); err == nil || !strings.Contains(err.Error(), `read for user "pipe"`) {
t.Errorf("another user of the descriptor: %v", err)
}
}
// After startup a descriptor, pipe or terminal is not read: a console source
// may read the same one. One read at startup stays for reloads.
func TestOnceReadSourcesAreReadAtStartupOnly(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
fd, err := syscall.Dup(int(r.Fd()))
r.Close()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { startupOver.Store(false); readOnce.Lock(); clear(readOnce.m); readOnce.Unlock() })
w.WriteString("pipe-secret\n")
w.Close()
path := fmt.Sprintf("/dev/fd/%d", fd)
if _, err := ReadPassword(path, "early", false); err != nil {
t.Fatalf("at startup: %v", err)
}
EndStartup()
if _, err := ReadPassword(os.DevNull, "late", false); err == nil || !strings.Contains(err.Error(), "only at startup") {
t.Fatalf("a device after startup: %v", err)
}
if pw, err := ReadPassword(path, "early", false); err != nil || pw != "pipe-secret" {
t.Fatalf("a reload of the early user: %q, %v", pw, err)
}
}
// A device is classed by the file it opens, not by stat: FreeBSD stats
// /dev/stdin as a device whatever it holds, so one that is no terminal is read.
func TestADeviceThatIsNoTerminalIsRead(t *testing.T) {
if _, err := ReadPassword(os.DevNull, "dev", false); err == nil || !strings.Contains(err.Error(), "password must be") {
t.Fatalf("%s: %v; want it read, and empty", os.DevNull, err)
}
}
// A /dev/fd/N lw opened itself, close-on-exec as Go opens every file, was
// never passed to it: it is refused and left open.
func TestOwnDescriptorIsNeverRead(t *testing.T) {
f, err := os.Open(os.DevNull)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if _, err := ReadPassword(fmt.Sprintf("/dev/fd/%d", f.Fd()), "pipe", false); err == nil || !strings.Contains(err.Error(), "not passed to lw") {
t.Fatalf("lw's own descriptor: %v", err)
}
if _, err := f.Stat(); err != nil {
t.Fatalf("lw's own descriptor was closed: %v", err)
}
}
// A listener's one user, from username and password_file, logs in with its
// password; another password and an unknown user fail alike.
func TestOneUserListenerAdmitsItsPassword(t *testing.T) {
f := newFixture(t)
o := config.AuthOptions{Username: "pipe", PasswordFile: f.write(t, "pipe.pass", "pipe-secret\n")}
l := f.listener(t, o, f.serverTLS, config.ChainListener, TCP)
res, _, err := f.connect(t, l, f.serverTLS, f.dialer(t, "pipe", "pipe-secret"), nil)
if err != nil || res.err != nil || res.adm.Identity.Name != "pipe" {
t.Fatalf("exchange: dialer %v, listener %v", err, res.err)
}
var seen []string
for _, d := range []*Policy{f.dialer(t, "pipe", "edge-01-secret"), f.dialer(t, "edge-01", "pipe-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 credentials file is validated whole, and Marshal round-trips. Each case
// breaks one rule of an otherwise valid file.
func TestCredentialsFileValidation(t *testing.T) {