feat(server): activate projected authentication safely
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
//go:build linux
|
||||
|
||||
package authconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/authprojection"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||
)
|
||||
|
||||
func TestProjectedAuthMutatorsBlockBeforeCanonicalWriteAndPublishOnlyEqualSnapshots(t *testing.T) {
|
||||
installation, spec := projectedAuthInstallation(t)
|
||||
adminPassword := writePasswordFile(t, "initial projected administrator password\n")
|
||||
userPassword := writePasswordFile(t, "projected ordinary user password\n")
|
||||
previous := runProjectedAuthMutation
|
||||
blockedObservations := 0
|
||||
runProjectedAuthMutation = func(ctx context.Context, canonicalRoot string, projection ProjectionSpec, mutate func() error) error {
|
||||
return previous(ctx, canonicalRoot, projection, func() error {
|
||||
status, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: projection.RuntimeRoot, UID: projection.UID, GID: projection.GID})
|
||||
if !errors.Is(err, authprojection.ErrBlocked) || status.Selector.State != "blocked" {
|
||||
t.Fatalf("projection before canonical mutation = %#v, %v; want blocked", status, err)
|
||||
}
|
||||
blockedObservations++
|
||||
return mutate()
|
||||
})
|
||||
}
|
||||
t.Cleanup(func() { runProjectedAuthMutation = previous })
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", adminPassword},
|
||||
{"user", "add", "operator", "--role", "user", "--password-file", userPassword},
|
||||
{"user", "set-password", "operator", "--password-file", adminPassword},
|
||||
{"user", "disable", "operator"},
|
||||
{"user", "enable", "operator"},
|
||||
{"user", "grant", "operator", "--role", "admin"},
|
||||
{"user", "revoke", "operator", "--role", "admin"},
|
||||
{"user", "logout-all", "operator", "--yes"},
|
||||
} {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := Run(context.Background(), installation, args, strings.NewReader(""), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("%v = %d, stdout=%q stderr=%q", args, code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertProjectedCanonicalReadyAndEqual(t, installation.AuthenticationDirectory(), spec)
|
||||
if strings.Contains(stdout.String()+stderr.String(), "projected administrator password") || strings.Contains(stdout.String()+stderr.String(), "$argon2id$") {
|
||||
t.Fatalf("%v leaked secret material", args)
|
||||
}
|
||||
}
|
||||
if blockedObservations != 8 {
|
||||
t.Fatalf("blocked observations = %d, want 8", blockedObservations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectedAuthMutationLeavesBlockedAfterChangedCanonicalFailure(t *testing.T) {
|
||||
installation, spec := projectedAuthInstallation(t)
|
||||
adminPassword := writePasswordFile(t, "initial projected administrator password\n")
|
||||
if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", adminPassword}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 {
|
||||
t.Fatalf("configure = %d", code)
|
||||
}
|
||||
previous := runProjectedAuthMutation
|
||||
runProjectedAuthMutation = func(ctx context.Context, canonicalRoot string, projection ProjectionSpec, mutate func() error) error {
|
||||
return previous(ctx, canonicalRoot, projection, func() error {
|
||||
if err := mutate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("synthetic-password-sentinel")
|
||||
})
|
||||
}
|
||||
t.Cleanup(func() { runProjectedAuthMutation = previous })
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := Run(context.Background(), installation, []string{"user", "logout-all", "admin", "--yes"}, strings.NewReader(""), &stdout, &stderr)
|
||||
if code == 0 || strings.Contains(stdout.String()+stderr.String(), "synthetic-password-sentinel") {
|
||||
t.Fatalf("changed mutation failure was accepted or leaked: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
_, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID})
|
||||
if !errors.Is(err, authprojection.ErrBlocked) {
|
||||
t.Fatalf("Inspect() error = %v, want blocked runtime projection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectedAuthMutatorFailuresRestoreOnlyUnchangedCanonicalState(t *testing.T) {
|
||||
mutators := []string{"configure", "user add", "user set-password", "user enable", "user disable", "user grant", "user revoke", "user logout-all"}
|
||||
for _, mutator := range mutators {
|
||||
t.Run(mutator+" restores ready before callback", func(t *testing.T) {
|
||||
installation, spec, args := preparedProjectedMutation(t, mutator)
|
||||
previous := runProjectedAuthMutation
|
||||
runProjectedAuthMutation = func(ctx context.Context, canonicalRoot string, projection ProjectionSpec, mutate func() error) error {
|
||||
return previous(ctx, canonicalRoot, projection, func() error {
|
||||
return errors.New("synthetic-password-sentinel")
|
||||
})
|
||||
}
|
||||
t.Cleanup(func() { runProjectedAuthMutation = previous })
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := Run(context.Background(), installation, args, strings.NewReader(""), &stdout, &stderr); code == 0 {
|
||||
t.Fatalf("%s accepted injected pre-mutation failure", mutator)
|
||||
}
|
||||
if strings.Contains(stdout.String()+stderr.String(), "synthetic-password-sentinel") {
|
||||
t.Fatalf("%s leaked injected failure: stdout=%q stderr=%q", mutator, stdout.String(), stderr.String())
|
||||
}
|
||||
assertProjectedCanonicalReadyAndEqual(t, installation.AuthenticationDirectory(), spec)
|
||||
})
|
||||
|
||||
t.Run(mutator+" leaves blocked after changed canonical error", func(t *testing.T) {
|
||||
var installation config.Installation
|
||||
var spec ProjectionSpec
|
||||
var args []string
|
||||
if mutator == "configure" {
|
||||
installation, spec = projectedAuthInstallation(t)
|
||||
passwordFile := writePasswordFile(t, "fresh projected bootstrap password\n")
|
||||
args = []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile}
|
||||
} else {
|
||||
installation, spec, args = preparedProjectedMutation(t, mutator)
|
||||
}
|
||||
previous := runProjectedAuthMutation
|
||||
runProjectedAuthMutation = func(ctx context.Context, canonicalRoot string, projection ProjectionSpec, mutate func() error) error {
|
||||
return previous(ctx, canonicalRoot, projection, func() error {
|
||||
if err := mutate(); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("synthetic-password-sentinel")
|
||||
})
|
||||
}
|
||||
t.Cleanup(func() { runProjectedAuthMutation = previous })
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := Run(context.Background(), installation, args, strings.NewReader(""), &stdout, &stderr); code == 0 {
|
||||
t.Fatalf("%s accepted changed-canonical injected failure", mutator)
|
||||
}
|
||||
if strings.Contains(stdout.String()+stderr.String(), "synthetic-password-sentinel") {
|
||||
t.Fatalf("%s leaked injected failure: stdout=%q stderr=%q", mutator, stdout.String(), stderr.String())
|
||||
}
|
||||
_, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID})
|
||||
if !errors.Is(err, authprojection.ErrBlocked) {
|
||||
t.Fatalf("%s Inspect() error = %v, want blocked projection", mutator, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectedAuthPublishStatusAndCheckFailClosed(t *testing.T) {
|
||||
installation, spec := projectedAuthInstallation(t)
|
||||
passwordFile := writePasswordFile(t, "projected authentication password\n")
|
||||
if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 {
|
||||
t.Fatalf("configure = %d", code)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := Run(context.Background(), installation, []string{"publish"}, strings.NewReader(""), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("publish = %d, stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if code := Run(context.Background(), installation, []string{"publish", "secret"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code == 0 {
|
||||
t.Fatal("publish accepted content arguments")
|
||||
}
|
||||
stdout.Reset()
|
||||
if code := Run(context.Background(), installation, []string{"status", "--json"}, strings.NewReader(""), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("status = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("status JSON = %q, %v", stdout.String(), err)
|
||||
}
|
||||
if len(raw) != 4 {
|
||||
t.Fatalf("status keys = %#v, want exactly projection public keys", raw)
|
||||
}
|
||||
for _, key := range []string{"state", "generation", "canonicalRevision", "equal"} {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Fatalf("status keys = %#v, missing %q", raw, key)
|
||||
}
|
||||
}
|
||||
var status struct {
|
||||
State string `json:"state"`
|
||||
Generation string `json:"generation"`
|
||||
CanonicalRevision string `json:"canonicalRevision"`
|
||||
Equal bool `json:"equal"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &status); err != nil || status.State != "ready" || !status.Equal || status.Generation == "" || status.CanonicalRevision == "" {
|
||||
t.Fatalf("status = %q, %#v, %v", stdout.String(), status, err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "password") || strings.Contains(stdout.String(), "$argon2id$") {
|
||||
t.Fatalf("status exposed secret material: %q", stdout.String())
|
||||
}
|
||||
|
||||
transaction, err := BeginExternalProjectionTransaction(context.Background(), installation.AuthenticationDirectory(), spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer transaction.Close()
|
||||
canonical, err := loadSnapshotBytes(installation.AuthenticationDirectory())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := Run(context.Background(), installation, []string{"status", "--json"}, strings.NewReader(""), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("blocked status JSON = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
raw = nil
|
||||
if err := json.Unmarshal(stdout.Bytes(), &raw); err != nil {
|
||||
t.Fatalf("blocked status JSON = %q, %v", stdout.String(), err)
|
||||
}
|
||||
if len(raw) != 4 {
|
||||
t.Fatalf("blocked status keys = %#v, want exactly projection public keys", raw)
|
||||
}
|
||||
for _, key := range []string{"state", "generation", "canonicalRevision", "equal"} {
|
||||
if _, ok := raw[key]; !ok {
|
||||
t.Fatalf("blocked status keys = %#v, missing %q", raw, key)
|
||||
}
|
||||
}
|
||||
status = struct {
|
||||
State string `json:"state"`
|
||||
Generation string `json:"generation"`
|
||||
CanonicalRevision string `json:"canonicalRevision"`
|
||||
Equal bool `json:"equal"`
|
||||
}{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &status); err != nil || status.State != "blocked" || status.Generation != "" || status.CanonicalRevision != canonical.CanonicalRevision || status.Equal {
|
||||
t.Fatalf("blocked status = %q, %#v, %v", stdout.String(), status, err)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := Run(context.Background(), installation, []string{"status"}, strings.NewReader(""), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("blocked status text = %d, stderr=%q", code, stderr.String())
|
||||
}
|
||||
wantText := "State: blocked\nGeneration: \nCanonical revision: " + canonical.CanonicalRevision + "\nEqual: false\n"
|
||||
if stdout.String() != wantText {
|
||||
t.Fatalf("blocked status text = %q, want %q", stdout.String(), wantText)
|
||||
}
|
||||
|
||||
calls := 0
|
||||
runner := runnerFunc(func(_ context.Context, _ []string, _ io.Reader) (compose.Result, error) {
|
||||
calls++
|
||||
return compose.Result{}, errors.New("backend diagnostic must not run")
|
||||
})
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := RunWithRunner(context.Background(), installation, []string{"check", "--json"}, strings.NewReader(""), &stdout, &stderr, runner); code == 0 || calls != 0 {
|
||||
t.Fatalf("check admitted blocked projection: code=%d calls=%d stdout=%q stderr=%q", code, calls, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireRuntimeAuthProjectionReadyRejectsMissingBlockedAndDivergentStates(t *testing.T) {
|
||||
for _, state := range []string{"missing", "blocked", "divergent"} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
installation, spec := projectedAuthInstallation(t)
|
||||
passwordFile := writePasswordFile(t, "projected readiness password\n")
|
||||
if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 {
|
||||
t.Fatalf("configure = %d", code)
|
||||
}
|
||||
if err := RequireRuntimeAuthProjectionReady(installation); err != nil {
|
||||
t.Fatalf("ready projection rejected: %v", err)
|
||||
}
|
||||
switch state {
|
||||
case "missing":
|
||||
if err := os.RemoveAll(spec.RuntimeRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case "blocked":
|
||||
transaction, err := BeginExternalProjectionTransaction(context.Background(), installation.AuthenticationDirectory(), spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = transaction.Close() })
|
||||
case "divergent":
|
||||
if err := MutateUsers(installation.AuthenticationDirectory(), func(registry *Registry) error {
|
||||
registry.Users[0].AuthRevision++
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := RequireRuntimeAuthProjectionReady(installation); err == nil {
|
||||
t.Fatalf("RequireRuntimeAuthProjectionReady accepted %s projection", state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectedAuthCommandsRefuseBeforeMutationWhenNotRoot(t *testing.T) {
|
||||
installation, _ := projectedAuthInstallation(t)
|
||||
passwordFile := writePasswordFile(t, "projected authentication password\n")
|
||||
previous := authProjectionEffectiveUID
|
||||
authProjectionEffectiveUID = func() int { return 1000 }
|
||||
t.Cleanup(func() { authProjectionEffectiveUID = previous })
|
||||
if code := Run(context.Background(), installation, []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", passwordFile}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code == 0 {
|
||||
t.Fatal("non-root projected configure succeeded")
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(installation.AuthenticationDirectory(), authFileName)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("canonical auth was written after non-root refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func projectedAuthInstallation(t *testing.T) (config.Installation, ProjectionSpec) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
canonicalRoot, runtimeRoot := filepath.Join(root, "canonical-auth"), filepath.Join(root, "runtime-auth")
|
||||
for _, directory := range []string{canonicalRoot, runtimeRoot} {
|
||||
if err := safeio.EnsurePrivateDirectory(directory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "operator.env"), []byte("SAFE_VALUE=1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
uid, gid := uint32(os.Geteuid()), uint32(os.Getegid())
|
||||
installation := config.Installation{Profile: "server", EnvFile: filepath.Join(root, "operator.env"), Authentication: config.Authentication{
|
||||
ConfigDirectory: canonicalRoot,
|
||||
RuntimeProjection: &config.RuntimeProjection{Directory: runtimeRoot, UID: uid, GID: gid},
|
||||
}}
|
||||
return installation, ProjectionSpec{RuntimeRoot: runtimeRoot, UID: uid, GID: gid}
|
||||
}
|
||||
|
||||
func assertProjectedCanonicalReadyAndEqual(t *testing.T, canonicalRoot string, spec ProjectionSpec) {
|
||||
t.Helper()
|
||||
status, err := authprojection.Inspect(authprojection.Spec{RuntimeRoot: spec.RuntimeRoot, UID: spec.UID, GID: spec.GID})
|
||||
if err != nil || status.Selector.State != "ready" {
|
||||
t.Fatalf("Inspect() = %#v, %v; want ready", status, err)
|
||||
}
|
||||
snapshot, err := loadSnapshotBytes(canonicalRoot)
|
||||
if err != nil || status.Snapshot.Generation != snapshot.Generation || status.Snapshot.CanonicalRevision != snapshot.CanonicalRevision {
|
||||
t.Fatalf("projection = %#v, canonical = %#v, %v; want equality", status.Snapshot, snapshot, err)
|
||||
}
|
||||
}
|
||||
|
||||
func preparedProjectedMutation(t *testing.T, mutator string) (config.Installation, ProjectionSpec, []string) {
|
||||
t.Helper()
|
||||
installation, spec := projectedAuthInstallation(t)
|
||||
adminPassword := writePasswordFile(t, "prepared projected administrator password\n")
|
||||
userPassword := writePasswordFile(t, "prepared projected user password\n")
|
||||
configure := []string{"configure", "--mode", "local", "--public-url", "http://127.0.0.1:8080", "--admin-user", "admin", "--password-file", adminPassword}
|
||||
if code := Run(context.Background(), installation, configure, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 {
|
||||
t.Fatalf("prepare %s configure = %d", mutator, code)
|
||||
}
|
||||
if mutator == "configure" {
|
||||
return installation, spec, configure
|
||||
}
|
||||
if code := Run(context.Background(), installation, []string{"user", "add", "operator", "--role", "user", "--password-file", userPassword}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 {
|
||||
t.Fatalf("prepare %s add = %d", mutator, code)
|
||||
}
|
||||
if mutator == "user enable" {
|
||||
if code := Run(context.Background(), installation, []string{"user", "disable", "operator"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 {
|
||||
t.Fatalf("prepare %s disable = %d", mutator, code)
|
||||
}
|
||||
}
|
||||
if mutator == "user revoke" {
|
||||
if code := Run(context.Background(), installation, []string{"user", "grant", "operator", "--role", "admin"}, strings.NewReader(""), ioDiscard{}, ioDiscard{}); code != 0 {
|
||||
t.Fatalf("prepare %s grant = %d", mutator, code)
|
||||
}
|
||||
}
|
||||
args := map[string][]string{
|
||||
"user add": {"user", "add", "second", "--role", "user", "--password-file", userPassword},
|
||||
"user set-password": {"user", "set-password", "operator", "--password-file", adminPassword},
|
||||
"user enable": {"user", "enable", "operator"},
|
||||
"user disable": {"user", "disable", "operator"},
|
||||
"user grant": {"user", "grant", "operator", "--role", "admin"},
|
||||
"user revoke": {"user", "revoke", "operator", "--role", "admin"},
|
||||
"user logout-all": {"user", "logout-all", "operator", "--yes"},
|
||||
}[mutator]
|
||||
if args == nil {
|
||||
t.Fatalf("unknown projected mutator %q", mutator)
|
||||
}
|
||||
return installation, spec, args
|
||||
}
|
||||
Reference in New Issue
Block a user