v0.31.0 refinements
This commit is contained in:
1 parent
bfcc15613a
commit
6bd5e12a60
52 files changed
+1948
-683
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user