Files
2026-10-10 12:01:54 -04:00

203 lines
6.9 KiB
Go

package main
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/lixenwraith/logwisp/internal/authz"
"github.com/lixenwraith/auth"
)
func runAuthTest(t *testing.T, args ...string) (code int, stdout, stderr string) {
return runCommand(t, "auth", args...)
}
// writeCredentials writes users sharing a cheap Argon2 profile. add-user
// reuses the file's profile, so only a new file costs the default 64 MiB.
func writeCredentials(t *testing.T, path string, users ...string) *authz.Credentials {
t.Helper()
c := &authz.Credentials{DecoyKey: bytes.Repeat([]byte{7}, 32)}
for _, u := range users {
cred, err := auth.DeriveCredential(u, "password-"+u, bytes.Repeat([]byte{1}, 16), 1, 64, 1)
if err != nil {
t.Fatal(err)
}
c.Users = append(c.Users, cred)
}
data, err := c.Marshal()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
return c
}
func TestAddUserCreatesPrivateFilesWithAMatchingVerifier(t *testing.T) {
dir := t.TempDir()
creds, pass := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge-01.pass")
code, stdout, stderr := runAuthTest(t, "add-user", "--credentials", creds, "-u", "edge-01", "--password-file", pass)
if code != 0 || stdout != "" {
t.Fatalf("exit %d, stdout %q, stderr: %s", code, stdout, stderr)
}
for _, path := range []string{creds, pass} {
fi, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o600 {
t.Fatalf("%s has mode %v, want 0600", path, fi.Mode().Perm())
}
}
raw, err := os.ReadFile(pass)
if err != nil || !strings.HasSuffix(string(raw), "\n") {
t.Fatalf("password file %q, %v: want one line", raw, err)
}
c, err := authz.LoadCredentials(creds)
if err != nil {
t.Fatal(err)
}
password, err := authz.ReadPassword(pass, "edge-01", false)
if err != nil {
t.Fatal(err)
}
u := c.Users[0]
want, err := auth.DeriveCredential("edge-01", password, u.Salt, u.ArgonTime, u.ArgonMemory, u.ArgonThreads)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(want.StoredKey, u.StoredKey) || !bytes.Equal(want.ServerKey, u.ServerKey) {
t.Fatal("stored verifier does not match the written password")
}
}
func TestAddUserNeverReplacesAPasswordWithoutASource(t *testing.T) {
dir := t.TempDir()
creds, typo := filepath.Join(dir, "users.toml"), filepath.Join(dir, "edge01.pass")
writeCredentials(t, creds, "edge-01")
before, err := os.ReadFile(creds)
if err != nil {
t.Fatal(err)
}
for _, extra := range [][]string{nil, {"-password-file", typo}} {
args := append([]string{"add-user", "-credentials", creds, "-user", "edge-01"}, extra...)
if code, _, stderr := runAuthTest(t, args...); code != 1 {
t.Fatalf("%v: exit %d, want 1; stderr: %s", extra, code, stderr)
}
}
after, err := os.ReadFile(creds)
if err != nil || !bytes.Equal(before, after) {
t.Fatalf("credentials file changed (%v)", err)
}
if _, err := os.Stat(typo); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("mistyped password file was created (%v)", err)
}
}
// The daemon reads a link's target: a rewrite through the link updates it
func TestRewriteKeepsFileModeDecoyKeyAndLink(t *testing.T) {
dir := t.TempDir()
creds, target, pass := filepath.Join(dir, "users.toml"), filepath.Join(dir, "real.toml"), filepath.Join(dir, "edge-02.pass")
orig := writeCredentials(t, target, "edge-01")
if err := os.Chmod(target, 0o640); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, creds); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(pass, []byte("correct horse battery\n"), 0o600); err != nil {
t.Fatal(err)
}
if code, _, stderr := runAuthTest(t, "add-user", "-credentials", creds, "-user", "edge-02", "-password-file", pass); code != 0 {
t.Fatalf("exit %d; stderr: %s", code, stderr)
}
if fi, err := os.Lstat(creds); err != nil || fi.Mode()&os.ModeSymlink == 0 {
t.Fatalf("link replaced by a file (%v)", err)
}
fi, err := os.Stat(target)
if err != nil {
t.Fatal(err)
}
if fi.Mode().Perm() != 0o640 {
t.Fatalf("mode %v, want 0640", fi.Mode().Perm())
}
c, err := authz.LoadCredentials(target)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(c.DecoyKey, orig.DecoyKey) || len(c.Users) != 2 || c.Users[1].Username != "edge-02" {
t.Fatalf("decoy key kept: %v, users: %d", bytes.Equal(c.DecoyKey, orig.DecoyKey), len(c.Users))
}
}
// A dangling link is refused rather than replaced: its missing target is the
// file the daemon would read
func TestAddUserRefusesADanglingLink(t *testing.T) {
dir := t.TempDir()
creds := filepath.Join(dir, "users.toml")
if err := os.Symlink(filepath.Join(dir, "missing", "users.toml"), creds); err != nil {
t.Fatal(err)
}
if code, _, _ := runAuthTest(t, "add-user", "-credentials", creds, "-user", "edge-01"); code != 1 {
t.Fatalf("exit %d, want 1", code)
}
if fi, err := os.Lstat(creds); err != nil || fi.Mode()&os.ModeSymlink == 0 {
t.Fatalf("link replaced (%v)", err)
}
}
// An empty file is how an operator picks the owner before the first user
func TestAddUserFillsAnEmptyFile(t *testing.T) {
creds := filepath.Join(t.TempDir(), "users.toml")
if err := os.WriteFile(creds, nil, 0o640); err != nil {
t.Fatal(err)
}
if code, _, stderr := runAuthTest(t, "add-user", "-credentials", creds, "-user", "edge-01"); code != 0 {
t.Fatalf("exit %d; stderr: %s", code, stderr)
}
fi, err := os.Stat(creds)
if err != nil || fi.Mode().Perm() != 0o640 {
t.Fatalf("mode %v (%v), want 0640", fi.Mode().Perm(), err)
}
if c, err := authz.LoadCredentials(creds); err != nil || len(c.Users) != 1 {
t.Fatalf("credentials after add-user: %v", err)
}
}
func TestRemoveUserRefusesTheLastUser(t *testing.T) {
creds := filepath.Join(t.TempDir(), "users.toml")
writeCredentials(t, creds, "edge-01", "edge-02")
if code, _, stderr := runAuthTest(t, "remove-user", "-credentials", creds, "-user", "edge-01"); code != 0 {
t.Fatalf("exit %d; stderr: %s", code, stderr)
}
if code, _, _ := runAuthTest(t, "remove-user", "-credentials", creds, "-user", "edge-02"); code != 1 {
t.Fatalf("removing the last user: exit %d, want 1", code)
}
c, err := authz.LoadCredentials(creds)
if err != nil || len(c.Users) != 1 || c.Users[0].Username != "edge-02" {
t.Fatalf("users after refusal: %v", err)
}
}
// The login is posted to the -url given plus /auth: a link-local URL keeps
// its zone escaped, not turned into one that no longer parses
func TestTokenLogsInAtTheURLGiven(t *testing.T) {
pass := filepath.Join(t.TempDir(), "edge-01.pass")
if err := os.WriteFile(pass, []byte("correct horse battery\n"), 0o600); err != nil {
t.Fatal(err)
}
// No such interface or listener: the dial fails at once, after the URL is formed
for _, rawURL := range []string{"https://[fe80::1%25nosuchif0]:1", "https://127.0.0.1:1?"} {
code, _, stderr := runAuthTest(t, "token", "-url", rawURL, "-user", "edge-01", "-password-file", pass)
if want := `"` + strings.TrimSuffix(rawURL, "?") + `/auth"`; code != 1 || !strings.Contains(stderr, want) {
t.Errorf("%s: exit %d, stderr: %s", rawURL, code, stderr)
}
}
}