diff --git a/cmd/elevate/README.md b/cmd/elevate/README.md new file mode 100644 index 00000000..420d6bab --- /dev/null +++ b/cmd/elevate/README.md @@ -0,0 +1,162 @@ +# `cmd/elevate` + +Local privileged helper for temporary local-admin elevation. + +## Overview + +`elevate` is a standalone daemon intended to run as root (Linux target today). +It accepts local IPC requests, verifies a challenge-response signature using pinned public keys, performs OS-level grant/revoke operations, and persists grant state for cleanup/recovery. + +### Core Components + +- `main.go` + - Loads config from env. + - Builds dependencies. + - Starts server + cleanup runner. +- `ipc/` + - Unix socket transport (Linux/macOS/Windows). + - Newline-delimited JSON framing. + - Socket permissions + optional socket owner/group (`THAND_ELEVATE_SOCKET_USER`, `THAND_ELEVATE_SOCKET_GROUP`). +- `handler/` + - Request router (`grant`/`revoke`). + - Challenge/response signature verification flow. + - Sanitized error responses and structured `slog` logging. +- `verify/` + - Nonce handling + signed payload validation. + - Ed25519 verification against compile-time pinned keys (`verify/keys/*.pem`). +- `grant/` + - Linux grant engine (`/etc/sudoers.d/thand-`). + - `visudo -cf` validation. + - Revoke removes request-specific sudoers entry. +- `state/` + - Atomic state persistence (`tmp + fsync + rename + dir fsync`). +- `cleanup.go` + - Startup and periodic sweep of expired grants. + - Revokes expired entries and removes state records. +- `tools/sign_request/` + - Local test utility to generate `request` + `signed_response` frames from a private key. + +## Configuration + +Environment variables: + +- `THAND_ELEVATE_SOCKET_PATH` (default: `/var/run/thand/elevate.sock`) +- `THAND_ELEVATE_SOCKET_USER` (default: unset; set socket owner user by name) +- `THAND_ELEVATE_SOCKET_GROUP` (default: unset; set socket group by name) +- `THAND_ELEVATE_SUDOERS_DIR` (default: `/etc/sudoers.d`) +- `THAND_ELEVATE_SUDOERS_FILE` (default: `/etc/sudoers`) +- `THAND_ELEVATE_VISUDO_BIN` (default: `visudo`) +- `THAND_ELEVATE_STATE_PATH` (default: `/var/lib/thand/elevate/state.json`) +- `THAND_ELEVATE_CLEANUP_INTERVAL` (default: `1m`) +- `THAND_ELEVATE_REQUEST_TIMEOUT` (default: `30s`) +- `THAND_ELEVATE_LOG_LEVEL` (default: `info`; `debug|info|warn|error`) + +## Testing + +### Unit and race tests + +```bash +cd cmd/elevate +go test ./... +go test -race ./... +``` + +### Build + +```bash +cd cmd/elevate +go build -o /home/tom/dev/agent/elevate ./ +``` + +### Run with real system paths (root) + +```bash +sudo mkdir -p /var/run/thand /var/lib/thand/elevate +sudo chown root:root /var/run/thand /var/lib/thand/elevate +sudo chmod 755 /var/run/thand /var/lib/thand/elevate +``` + +```bash +sudo env \ + THAND_ELEVATE_SOCKET_PATH=/var/run/thand/elevate.sock \ + THAND_ELEVATE_SOCKET_USER="tom" \ + THAND_ELEVATE_SOCKET_GROUP="tom" \ + THAND_ELEVATE_SUDOERS_DIR=/etc/sudoers.d \ + THAND_ELEVATE_SUDOERS_FILE=/etc/sudoers \ + THAND_ELEVATE_VISUDO_BIN=visudo \ + THAND_ELEVATE_STATE_PATH=/var/lib/thand/elevate/state.json \ + THAND_ELEVATE_CLEANUP_INTERVAL=1m \ + THAND_ELEVATE_REQUEST_TIMEOUT=15m \ + THAND_ELEVATE_LOG_LEVEL=debug \ + /home/tom/dev/agent/elevate +``` + +## Manual Protocol Smoke Test + +1. Open socket session (same connection for both frames): + +```bash +socat - UNIX-CONNECT:/var/run/thand/elevate.sock +``` + +2. Send a request frame: + +```json +{"type":"request","action":"grant","workflow_id":"wf-manual-1","request_id":"req-manual-1","username":"alice","duration_seconds":600} +``` + +3. Copy nonce from challenge response. + +4. (Success path) Generate a local test keypair and pin the public key: + +```bash +KEYDIR="$(mktemp -d /tmp/elevate-keys-XXXXXX)" +cd /home/tom/dev/agent/cmd/elevate +go run ./tools/generate_test_key \ + -out-dir "$KEYDIR" \ + -key-id local-test-key +``` + +Copy the generated public key into pinned keys, rebuild, restart: + +```bash +KEY_ID="local-test-key" +cp "$KEYDIR/${KEY_ID}.pem" "/home/tom/dev/agent/cmd/elevate/verify/keys/${KEY_ID}.pem" +cd /home/tom/dev/agent/cmd/elevate +go build -o /home/tom/dev/agent/elevate ./ +# restart your running elevate process +``` + +5. Generate frames with signer tool using matching private key + key id: + +```bash +cd /home/tom/dev/agent/cmd/elevate +GOCACHE=/tmp/gocache go run ./tools/sign_request \ + -private-key "$KEYDIR/${KEY_ID}.private.pem" \ + -key-id "$KEY_ID" \ + -nonce "" \ + -action grant \ + -workflow-id wf-manual-1 \ + -request-id req-manual-1 \ + -username alice \ + -duration-seconds 600 +``` + +This outputs two lines: +- `request` JSON +- `signed_response` JSON + +Paste the `signed_response` line into the open socket session as the second frame. + +Expected: +- success: `{"type":"result","status":"ok",...}` + +Negative-path tip: +- Use an unknown `key_id` or mismatched private key to get `{"status":"error","error":"unauthorized"}`. + +## Notes + +- Pinned key IDs come from filenames in `cmd/elevate/verify/keys/*.pem`. +- No production keys are committed by default. You must add at least one `.pem` key file before starting the daemon without override options. +- Changing pinned keys requires rebuilding/restarting the helper. +- Helper has no network code path; signature authority is external to this binary. diff --git a/cmd/elevate/cleanup.go b/cmd/elevate/cleanup.go new file mode 100644 index 00000000..5df6ca51 --- /dev/null +++ b/cmd/elevate/cleanup.go @@ -0,0 +1,155 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" + "github.com/thand-io/agent/cmd/elevate/handler" +) + +// CleanupRunner revokes and removes expired grants on startup and periodically. +type CleanupRunner struct { + store handler.StateStore + grants handler.GrantEngine + clock handler.Clock + interval time.Duration + retention time.Duration + logger *slog.Logger +} + +// NewCleanupRunner builds a cleanup runner that sweeps expired grants. +func NewCleanupRunner(store handler.StateStore, grants handler.GrantEngine, clock handler.Clock, interval time.Duration, retention time.Duration, logger *slog.Logger) (*CleanupRunner, error) { + if store == nil { + return nil, fmt.Errorf("state store is required") + } + if grants == nil { + return nil, fmt.Errorf("grant engine is required") + } + if clock == nil { + return nil, fmt.Errorf("clock is required") + } + if interval <= 0 { + return nil, fmt.Errorf("cleanup interval must be > 0") + } + if retention <= 0 { + return nil, fmt.Errorf("state retention must be > 0") + } + if logger == nil { + logger = slog.Default() + } + + return &CleanupRunner{ + store: store, + grants: grants, + clock: clock, + interval: interval, + retention: retention, + logger: logger, + }, nil +} + +// Run executes one startup sweep and then continues periodic sweeps until context cancellation. +func (c *CleanupRunner) Run(ctx context.Context) error { + if err := c.runOnce(ctx); err != nil { + if isCleanupShutdownError(ctx, err) { + return nil + } + return err + } + + ticker := time.NewTicker(c.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := c.runOnce(ctx); err != nil { + if isCleanupShutdownError(ctx, err) { + return nil + } + return err + } + } + } +} + +func (c *CleanupRunner) runOnce(ctx context.Context) error { + grants, err := c.store.List(ctx) + if err != nil { + return fmt.Errorf("list state grants: %w", err) + } + + nowMono := c.clock.NowMonoNS() + nowWall := c.clock.NowWallUTC() + + for _, g := range grants { + if isCompleted(g) { + if !isRetentionExpired(g, nowWall, c.retention) { + continue + } + if err := c.store.Delete(ctx, g.RequestID); err != nil { + return fmt.Errorf("delete retained grant %q: %w", g.RequestID, err) + } + continue + } + + if !isExpired(g, nowMono, nowWall) { + continue + } + + if !g.WasAlreadyPrivileged { + if err := c.grants.Revoke(ctx, domain.RevokeRequest{ + RequestID: g.RequestID, + WorkflowID: g.WorkflowID, + Username: g.Username, + }); err != nil { + return fmt.Errorf("revoke expired grant %q: %w", g.RequestID, err) + } + c.logger.Info("admin revoked by cleanup", + "component", "elevate_cleanup", + "request_id", g.RequestID, + "workflow_id", g.WorkflowID, + "username", g.Username, + "reason", "expired", + ) + } + + g.CompletedAtWallUTC = nowWall + if err := c.store.Put(ctx, g); err != nil { + return fmt.Errorf("persist completed grant %q: %w", g.RequestID, err) + } + } + + return nil +} + +func isCompleted(grant domain.GrantState) bool { + return !grant.CompletedAtWallUTC.IsZero() +} + +func isRetentionExpired(grant domain.GrantState, nowWallUTC time.Time, retention time.Duration) bool { + if grant.CompletedAtWallUTC.IsZero() || nowWallUTC.IsZero() || retention <= 0 { + return false + } + return !grant.CompletedAtWallUTC.Add(retention).After(nowWallUTC) +} + +func isExpired(grant domain.GrantState, nowMonoNS int64, nowWallUTC time.Time) bool { + return domain.IsExpiredGrantState(grant, nowMonoNS, nowWallUTC) +} + +func isCleanupShutdownError(ctx context.Context, err error) bool { + if err == nil { + return false + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return true + } + return ctx.Err() != nil && errors.Is(err, ctx.Err()) +} diff --git a/cmd/elevate/cleanup_test.go b/cmd/elevate/cleanup_test.go new file mode 100644 index 00000000..15d3366a --- /dev/null +++ b/cmd/elevate/cleanup_test.go @@ -0,0 +1,360 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" +) + +type stubStore struct { + mu sync.Mutex + grants []domain.GrantState + listErr error + putErr error + deleteErr error +} + +func (s *stubStore) Put(ctx context.Context, grant domain.GrantState) error { + _ = ctx + if s.putErr != nil { + return s.putErr + } + s.mu.Lock() + defer s.mu.Unlock() + for i := range s.grants { + if s.grants[i].RequestID == grant.RequestID { + s.grants[i] = grant + return nil + } + } + s.grants = append(s.grants, grant) + return nil +} + +func (s *stubStore) Delete(ctx context.Context, requestID string) error { + _ = ctx + if s.deleteErr != nil { + return s.deleteErr + } + s.mu.Lock() + defer s.mu.Unlock() + filtered := s.grants[:0] + for _, g := range s.grants { + if g.RequestID != requestID { + filtered = append(filtered, g) + } + } + s.grants = filtered + return nil +} + +func (s *stubStore) List(ctx context.Context) ([]domain.GrantState, error) { + _ = ctx + if s.listErr != nil { + return nil, s.listErr + } + s.mu.Lock() + defer s.mu.Unlock() + out := make([]domain.GrantState, len(s.grants)) + copy(out, s.grants) + return out, nil +} + +type stubGrantEngine struct { + mu sync.Mutex + revoked []string + err error + revokeC chan string +} + +func (g *stubGrantEngine) Grant(ctx context.Context, req domain.GrantRequest) (domain.GrantResult, error) { + _ = ctx + _ = req + return domain.GrantResult{}, errors.New("not implemented") +} + +func (g *stubGrantEngine) Revoke(ctx context.Context, req domain.RevokeRequest) error { + _ = ctx + if g.err != nil { + return g.err + } + g.mu.Lock() + defer g.mu.Unlock() + g.revoked = append(g.revoked, req.RequestID) + if g.revokeC != nil { + select { + case g.revokeC <- req.RequestID: + default: + } + } + return nil +} + +func (g *stubGrantEngine) revokedCount() int { + g.mu.Lock() + defer g.mu.Unlock() + return len(g.revoked) +} + +type stubClock struct { + mono atomic.Int64 + wall time.Time +} + +func (c *stubClock) NowMonoNS() int64 { return c.mono.Load() } +func (c *stubClock) NowWallUTC() time.Time { return c.wall } + +func TestIsExpired(t *testing.T) { + baseWall := time.Date(2026, 2, 22, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + grant domain.GrantState + nowMono int64 + nowWall time.Time + want bool + }{ + { + name: "not expired when wall and monotonic are both active", + grant: domain.GrantState{ + GrantedAtMonoNS: 100, + GrantedAtWallUTC: baseWall, + DurationSeconds: 10, + }, + nowMono: 105, + nowWall: baseWall, + want: false, + }, + { + name: "expired when wall clock has expired", + grant: domain.GrantState{ + GrantedAtMonoNS: 100, + GrantedAtWallUTC: baseWall, + DurationSeconds: 10, + }, + nowMono: 105, + nowWall: baseWall.Add(11 * time.Second), + want: true, + }, + { + name: "expired when wall clock is active but monotonic has expired", + grant: domain.GrantState{ + GrantedAtMonoNS: 100, + GrantedAtWallUTC: baseWall, + DurationSeconds: 10, + }, + nowMono: 100 + int64(10*time.Second), + nowWall: baseWall, + want: true, + }, + { + name: "not expired after restart when wall clock is still active", + grant: domain.GrantState{ + GrantedAtMonoNS: 1_000_000, + GrantedAtWallUTC: baseWall, + DurationSeconds: 10, + }, + nowMono: 1, + nowWall: baseWall.Add(5 * time.Second), + want: false, + }, + { + name: "expired after restart when wall clock has expired", + grant: domain.GrantState{ + GrantedAtMonoNS: 1_000_000, + GrantedAtWallUTC: baseWall, + DurationSeconds: 10, + }, + nowMono: 1, + nowWall: baseWall.Add(11 * time.Second), + want: true, + }, + { + name: "invalid duration expires", + grant: domain.GrantState{ + DurationSeconds: 0, + }, + nowMono: 1, + nowWall: baseWall, + want: true, + }, + } + + for _, tc := range tests { + if got := isExpired(tc.grant, tc.nowMono, tc.nowWall); got != tc.want { + t.Fatalf("%s: got %v want %v", tc.name, got, tc.want) + } + } +} + +func TestCleanupRunStartupSweep(t *testing.T) { + store := &stubStore{ + grants: []domain.GrantState{ + {RequestID: "expired", WorkflowID: "wf", Username: "alice", GrantedAtMonoNS: 1, GrantedAtWallUTC: time.Date(2026, 2, 22, 11, 59, 58, 0, time.UTC), DurationSeconds: 1}, + {RequestID: "active", WorkflowID: "wf", Username: "bob", GrantedAtMonoNS: 1000, GrantedAtWallUTC: time.Date(2026, 2, 22, 12, 0, 0, 0, time.UTC), DurationSeconds: 3600}, + }, + } + engine := &stubGrantEngine{} + clock := &stubClock{ + wall: time.Date(2026, 2, 22, 12, 0, 0, 0, time.UTC), + } + clock.mono.Store(2_000_000_000) + + runner, err := NewCleanupRunner(store, engine, clock, 10*time.Millisecond, 24*time.Hour, slog.Default()) + if err != nil { + t.Fatalf("NewCleanupRunner failed: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if err := runner.Run(ctx); err != nil { + t.Fatalf("Run failed: %v", err) + } + if engine.revokedCount() != 1 { + t.Fatalf("expected 1 revoke, got %d", engine.revokedCount()) + } + + left, err := store.List(context.Background()) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(left) != 2 { + t.Fatalf("unexpected grants left: %+v", left) + } + expired, foundExpired := findGrantStateByID(left, "expired") + active, foundActive := findGrantStateByID(left, "active") + if !foundExpired || !foundActive { + t.Fatalf("unexpected grants left: %+v", left) + } + if expired.CompletedAtWallUTC.IsZero() { + t.Fatalf("expected expired grant to be retained as completed: %+v", expired) + } + if !active.CompletedAtWallUTC.IsZero() { + t.Fatalf("expected active grant to remain active: %+v", active) + } +} + +func TestCleanupRunPeriodicSweep(t *testing.T) { + store := &stubStore{ + grants: []domain.GrantState{ + { + RequestID: "will-expire", + WorkflowID: "wf", + Username: "alice", + GrantedAtMonoNS: 1, + GrantedAtWallUTC: time.Date(2026, 2, 22, 12, 0, 0, 0, time.UTC), + DurationSeconds: 1, + }, + }, + } + engine := &stubGrantEngine{revokeC: make(chan string, 1)} + clock := &stubClock{ + wall: time.Date(2026, 2, 22, 12, 0, 0, 0, time.UTC), + } + clock.mono.Store(0) + + runner, err := NewCleanupRunner(store, engine, clock, 10*time.Millisecond, 24*time.Hour, slog.Default()) + if err != nil { + t.Fatalf("NewCleanupRunner failed: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- runner.Run(ctx) + }() + + clock.mono.Store(2 * int64(time.Second)) + + select { + case requestID := <-engine.revokeC: + if requestID != "will-expire" { + t.Fatalf("unexpected revoke request id: got %q want %q", requestID, "will-expire") + } + case <-time.After(250 * time.Millisecond): + t.Fatal("timed out waiting for periodic revoke") + } + + cancel() + + if err := <-done; err != nil { + t.Fatalf("Run failed: %v", err) + } + if engine.revokedCount() == 0 { + t.Fatal("expected periodic revoke to occur") + } +} + +func TestCleanupRunTreatsCanceledStoreOperationAsGracefulShutdown(t *testing.T) { + store := &stubStore{listErr: context.Canceled} + engine := &stubGrantEngine{} + clock := &stubClock{wall: time.Date(2026, 2, 22, 12, 0, 0, 0, time.UTC)} + clock.mono.Store(1) + + runner, err := NewCleanupRunner(store, engine, clock, 10*time.Millisecond, 24*time.Hour, slog.Default()) + if err != nil { + t.Fatalf("NewCleanupRunner failed: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + if err := runner.Run(ctx); err != nil { + t.Fatalf("expected graceful shutdown on canceled store operation, got %v", err) + } +} + +func TestCleanupPurgesCompletedGrantAfterRetention(t *testing.T) { + completedAt := time.Date(2026, 2, 22, 12, 0, 0, 0, time.UTC) + store := &stubStore{ + grants: []domain.GrantState{ + { + RequestID: "completed", + WorkflowID: "wf", + Username: "alice", + GrantedAtMonoNS: 1, + GrantedAtWallUTC: completedAt.Add(-time.Hour), + DurationSeconds: 60, + CompletedAtWallUTC: completedAt, + }, + }, + } + engine := &stubGrantEngine{} + clock := &stubClock{wall: completedAt.Add(25 * time.Hour)} + clock.mono.Store(1) + + runner, err := NewCleanupRunner(store, engine, clock, time.Minute, 24*time.Hour, slog.Default()) + if err != nil { + t.Fatalf("NewCleanupRunner failed: %v", err) + } + + if err := runner.runOnce(context.Background()); err != nil { + t.Fatalf("runOnce failed: %v", err) + } + + left, err := store.List(context.Background()) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(left) != 0 { + t.Fatalf("expected retained grant to be purged, got %+v", left) + } +} + +func findGrantStateByID(grants []domain.GrantState, requestID string) (domain.GrantState, bool) { + for _, grant := range grants { + if grant.RequestID == requestID { + return grant, true + } + } + return domain.GrantState{}, false +} diff --git a/cmd/elevate/clock/clock.go b/cmd/elevate/clock/clock.go new file mode 100644 index 00000000..5e9d98a9 --- /dev/null +++ b/cmd/elevate/clock/clock.go @@ -0,0 +1,22 @@ +package clock + +import ( + "time" +) + +// Clock provides wall-clock and monotonic time for elevate. +type Clock struct { + started time.Time +} + +// NewClock constructs a clock instance. +func NewClock() *Clock { + return &Clock{ + started: time.Now(), + } +} + +// NowWallUTC returns the current wall time in UTC. +func (*Clock) NowWallUTC() time.Time { + return time.Now().UTC() +} diff --git a/cmd/elevate/clock/mono_linux.go b/cmd/elevate/clock/mono_linux.go new file mode 100644 index 00000000..b73449e1 --- /dev/null +++ b/cmd/elevate/clock/mono_linux.go @@ -0,0 +1,18 @@ +//go:build linux + +package clock + +import ( + "time" + + "golang.org/x/sys/unix" +) + +// NowMonoNS returns suspend-inclusive monotonic nanoseconds since boot on Linux. +func (c *Clock) NowMonoNS() int64 { + var ts unix.Timespec + if err := unix.ClockGettime(unix.CLOCK_BOOTTIME, &ts); err != nil { + return time.Since(c.started).Nanoseconds() + } + return ts.Nano() +} diff --git a/cmd/elevate/clock/mono_macos.go b/cmd/elevate/clock/mono_macos.go new file mode 100644 index 00000000..69db78cc --- /dev/null +++ b/cmd/elevate/clock/mono_macos.go @@ -0,0 +1,18 @@ +//go:build darwin + +package clock + +import ( + "time" + + "golang.org/x/sys/unix" +) + +// NowMonoNS returns suspend-inclusive monotonic nanoseconds since boot on macOS. +func (c *Clock) NowMonoNS() int64 { + var ts unix.Timespec + if err := unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts); err != nil { + return time.Since(c.started).Nanoseconds() + } + return ts.Nano() +} diff --git a/cmd/elevate/clock/mono_windows.go b/cmd/elevate/clock/mono_windows.go new file mode 100644 index 00000000..d8d02119 --- /dev/null +++ b/cmd/elevate/clock/mono_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package clock + +import ( + "syscall" + "time" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetTickCount64 = kernel32.NewProc("GetTickCount64") +) + +// NowMonoNS returns monotonic nanoseconds since system boot. +func (c *Clock) NowMonoNS() int64 { + ms, _, callErr := procGetTickCount64.Call() + if callErr == syscall.Errno(0) { + return int64(ms) * int64(time.Millisecond) + } + return time.Since(c.started).Nanoseconds() +} diff --git a/cmd/elevate/config/config.go b/cmd/elevate/config/config.go new file mode 100644 index 00000000..bea82734 --- /dev/null +++ b/cmd/elevate/config/config.go @@ -0,0 +1,242 @@ +package config + +import ( + "fmt" + "log/slog" + "os" + "runtime" + "strings" + "time" + + "github.com/thand-io/agent/cmd/elevate/identity" +) + +const ( + // EnvSocketPath overrides the helper IPC socket path. + EnvSocketPath = "THAND_ELEVATE_SOCKET_PATH" + // DefaultSocketPath is the default helper IPC socket path on Unix platforms. + DefaultSocketPath = "/var/run/thand/elevate.sock" + // DefaultSocketPathWindows is the default helper IPC socket path on Windows. + DefaultSocketPathWindows = `C:\ProgramData\Thand\elevate.sock` + // EnvSudoersDir overrides the sudoers include directory for grant files. + EnvSudoersDir = "THAND_ELEVATE_SUDOERS_DIR" + // DefaultSudoersDir is the default sudoers include directory for grant files. + DefaultSudoersDir = "/etc/sudoers.d" + // EnvSudoersFile overrides the base sudoers file used for #includedir checks. + EnvSudoersFile = "THAND_ELEVATE_SUDOERS_FILE" + // DefaultSudoersFile is the default base sudoers file used for #includedir checks. + DefaultSudoersFile = "/etc/sudoers" + // EnvVisudoBin overrides the visudo binary path/name. + EnvVisudoBin = "THAND_ELEVATE_VISUDO_BIN" + // DefaultVisudoBin is the default visudo binary name. + DefaultVisudoBin = "visudo" + // EnvStatePath overrides the persisted grant state file path. + EnvStatePath = "THAND_ELEVATE_STATE_PATH" + // DefaultStatePath is the default persisted grant state file path. + DefaultStatePath = "/var/lib/thand/elevate/state.json" + // DefaultStatePathWindows is the default persisted grant state file path on Windows. + DefaultStatePathWindows = `C:\ProgramData\Thand\elevate\state.json` + // EnvCleanupInterval overrides the periodic cleanup interval duration. + EnvCleanupInterval = "THAND_ELEVATE_CLEANUP_INTERVAL" + // DefaultCleanup is the default periodic cleanup interval. + DefaultCleanup = 1 * time.Minute + // EnvRequestTimeout overrides per-request handler timeout duration. + EnvRequestTimeout = "THAND_ELEVATE_REQUEST_TIMEOUT" + // DefaultRequestTimeout is the default per-request handler timeout. + DefaultRequestTimeout = 30 * time.Second + // EnvStateRetention overrides how long completed grant tombstones are retained. + EnvStateRetention = "THAND_ELEVATE_STATE_RETENTION" + // DefaultStateRetention is the default completed grant retention window. + DefaultStateRetention = 24 * time.Hour + // EnvSocketUser optionally sets socket owner username. + EnvSocketUser = "THAND_ELEVATE_SOCKET_USER" + // EnvSocketGroup optionally sets socket group name. + EnvSocketGroup = "THAND_ELEVATE_SOCKET_GROUP" + // EnvLogLevel overrides helper log level. + EnvLogLevel = "THAND_ELEVATE_LOG_LEVEL" + // DefaultLogLevel is the default helper log level. + DefaultLogLevel = "info" + // EnvWindowsAdminGroup overrides the local administrator group name on Windows. + EnvWindowsAdminGroup = "THAND_ELEVATE_WINDOWS_ADMIN_GROUP" +) + +// Config contains runtime configuration for the elevate helper. +type Config struct { + SocketPath string + SudoersDir string + SudoersFile string + VisudoBin string + StatePath string + + CleanupInterval time.Duration + RequestTimeout time.Duration + StateRetention time.Duration + SocketUser string + SocketGroup string + LogLevel string + WindowsAdminGroup string +} + +// LoadFromEnv loads helper configuration from environment variables with defaults. +func LoadFromEnv() (*Config, error) { + socketPath := strings.TrimSpace(os.Getenv(EnvSocketPath)) + if socketPath == "" { + socketPath = defaultSocketPath() + } + + sudoersDir := strings.TrimSpace(os.Getenv(EnvSudoersDir)) + if sudoersDir == "" { + sudoersDir = DefaultSudoersDir + } + + sudoersFile := strings.TrimSpace(os.Getenv(EnvSudoersFile)) + if sudoersFile == "" { + sudoersFile = DefaultSudoersFile + } + + visudoBin := strings.TrimSpace(os.Getenv(EnvVisudoBin)) + if visudoBin == "" { + visudoBin = DefaultVisudoBin + } + + statePath := strings.TrimSpace(os.Getenv(EnvStatePath)) + if statePath == "" { + statePath = defaultStatePath() + } + + cleanupInterval := DefaultCleanup + if configured := strings.TrimSpace(os.Getenv(EnvCleanupInterval)); configured != "" { + parsed, err := time.ParseDuration(configured) + if err != nil { + return nil, fmt.Errorf("parse cleanup interval: %w", err) + } + cleanupInterval = parsed + } + requestTimeout := DefaultRequestTimeout + if configured := strings.TrimSpace(os.Getenv(EnvRequestTimeout)); configured != "" { + parsed, err := time.ParseDuration(configured) + if err != nil { + return nil, fmt.Errorf("parse request timeout: %w", err) + } + requestTimeout = parsed + } + stateRetention := DefaultStateRetention + if configured := strings.TrimSpace(os.Getenv(EnvStateRetention)); configured != "" { + parsed, err := time.ParseDuration(configured) + if err != nil { + return nil, fmt.Errorf("parse state retention: %w", err) + } + stateRetention = parsed + } + socketUser := strings.TrimSpace(os.Getenv(EnvSocketUser)) + socketGroup := strings.TrimSpace(os.Getenv(EnvSocketGroup)) + logLevel := strings.TrimSpace(os.Getenv(EnvLogLevel)) + if logLevel == "" { + logLevel = DefaultLogLevel + } + windowsAdminGroup := strings.TrimSpace(os.Getenv(EnvWindowsAdminGroup)) + + cfg := &Config{ + SocketPath: socketPath, + SudoersDir: sudoersDir, + SudoersFile: sudoersFile, + VisudoBin: visudoBin, + StatePath: statePath, + + CleanupInterval: cleanupInterval, + RequestTimeout: requestTimeout, + StateRetention: stateRetention, + SocketUser: socketUser, + SocketGroup: socketGroup, + LogLevel: logLevel, + WindowsAdminGroup: windowsAdminGroup, + } + + if err := cfg.Validate(); err != nil { + return nil, err + } + + return cfg, nil +} + +func defaultSocketPath() string { + if runtime.GOOS == "windows" { + return DefaultSocketPathWindows + } + return DefaultSocketPath +} + +func defaultStatePath() string { + if runtime.GOOS == "windows" { + return DefaultStatePathWindows + } + return DefaultStatePath +} + +// Validate ensures required configuration fields are present and safe. +func (c *Config) Validate() error { + return c.validateForOS(runtime.GOOS) +} + +func (c *Config) validateForOS(goos string) error { + if c == nil { + return fmt.Errorf("config is required") + } + + if strings.TrimSpace(c.SocketPath) == "" { + return fmt.Errorf("socket path is required") + } + if goos != "windows" { + if strings.TrimSpace(c.SudoersDir) == "" { + return fmt.Errorf("sudoers dir is required") + } + if strings.TrimSpace(c.SudoersFile) == "" { + return fmt.Errorf("sudoers file is required") + } + if strings.TrimSpace(c.VisudoBin) == "" { + return fmt.Errorf("visudo binary is required") + } + } + if strings.TrimSpace(c.StatePath) == "" { + return fmt.Errorf("state path is required") + } + if c.CleanupInterval <= 0 { + return fmt.Errorf("cleanup interval must be > 0") + } + if c.RequestTimeout <= 0 { + return fmt.Errorf("request timeout must be > 0") + } + if c.StateRetention <= 0 { + return fmt.Errorf("state retention must be > 0") + } + if _, err := ParseLogLevel(c.LogLevel); err != nil { + return err + } + if c.SocketUser != "" && !identity.ValidAccountName(c.SocketUser) { + return fmt.Errorf("socket user is invalid") + } + if c.SocketGroup != "" && !identity.ValidAccountName(c.SocketGroup) { + return fmt.Errorf("socket group is invalid") + } + if goos == "windows" && c.WindowsAdminGroup != "" && !identity.ValidWindowsAdminGroup(c.WindowsAdminGroup) { + return fmt.Errorf("windows admin group is invalid") + } + + return nil +} + +// ParseLogLevel converts textual configuration into an slog level. +func ParseLogLevel(raw string) (slog.Level, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "debug": + return slog.LevelDebug, nil + case "", "info": + return slog.LevelInfo, nil + case "warn", "warning": + return slog.LevelWarn, nil + case "error": + return slog.LevelError, nil + default: + return 0, fmt.Errorf("invalid log level: %q", raw) + } +} diff --git a/cmd/elevate/config/config_test.go b/cmd/elevate/config/config_test.go new file mode 100644 index 00000000..7edc36d1 --- /dev/null +++ b/cmd/elevate/config/config_test.go @@ -0,0 +1,383 @@ +package config + +import ( + "testing" + "time" +) + +func TestLoadFromEnvUsesDefaultSocketPath(t *testing.T) { + t.Setenv(EnvSocketPath, "") + t.Setenv(EnvSudoersDir, "") + t.Setenv(EnvSudoersFile, "") + t.Setenv(EnvVisudoBin, "") + t.Setenv(EnvStatePath, "") + t.Setenv(EnvCleanupInterval, "") + t.Setenv(EnvRequestTimeout, "") + t.Setenv(EnvStateRetention, "") + t.Setenv(EnvSocketUser, "") + t.Setenv(EnvSocketGroup, "") + t.Setenv(EnvLogLevel, "") + t.Setenv(EnvWindowsAdminGroup, "") + + cfg, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv failed: %v", err) + } + + if cfg.SocketPath != defaultSocketPath() { + t.Fatalf("unexpected socket path: got %q want %q", cfg.SocketPath, defaultSocketPath()) + } + if cfg.SudoersDir != DefaultSudoersDir { + t.Fatalf("unexpected sudoers dir: got %q want %q", cfg.SudoersDir, DefaultSudoersDir) + } + if cfg.SudoersFile != DefaultSudoersFile { + t.Fatalf("unexpected sudoers file: got %q want %q", cfg.SudoersFile, DefaultSudoersFile) + } + if cfg.VisudoBin != DefaultVisudoBin { + t.Fatalf("unexpected visudo bin: got %q want %q", cfg.VisudoBin, DefaultVisudoBin) + } + if cfg.StatePath != defaultStatePath() { + t.Fatalf("unexpected state path: got %q want %q", cfg.StatePath, defaultStatePath()) + } + if cfg.CleanupInterval != DefaultCleanup { + t.Fatalf("unexpected cleanup interval: got %s want %s", cfg.CleanupInterval, DefaultCleanup) + } + if cfg.RequestTimeout != DefaultRequestTimeout { + t.Fatalf("unexpected request timeout: got %s want %s", cfg.RequestTimeout, DefaultRequestTimeout) + } + if cfg.StateRetention != DefaultStateRetention { + t.Fatalf("unexpected state retention: got %s want %s", cfg.StateRetention, DefaultStateRetention) + } + if cfg.SocketUser != "" { + t.Fatalf("unexpected socket user: got %q want empty", cfg.SocketUser) + } + if cfg.SocketGroup != "" { + t.Fatalf("unexpected socket group: got %q want empty", cfg.SocketGroup) + } + if cfg.LogLevel != DefaultLogLevel { + t.Fatalf("unexpected log level: got %q want %q", cfg.LogLevel, DefaultLogLevel) + } + if cfg.WindowsAdminGroup != "" { + t.Fatalf("unexpected windows admin group: got %q want empty", cfg.WindowsAdminGroup) + } +} + +func TestLoadFromEnvUsesConfiguredSocketPath(t *testing.T) { + wantSocket := "/tmp/custom-elevate.sock" + wantSudoers := "/tmp/custom-sudoers.d" + wantSudoersFile := "/tmp/custom-sudoers" + wantVisudo := "/usr/local/bin/visudo" + wantStatePath := "/tmp/custom-state.json" + wantCleanup := "30s" + wantRequestTimeout := "5m" + wantStateRetention := "48h" + wantSocketUser := "tom" + wantSocketGroup := "thand" + wantLogLevel := "warn" + wantWindowsAdminGroup := "Administrators" + t.Setenv(EnvSocketPath, wantSocket) + t.Setenv(EnvSudoersDir, wantSudoers) + t.Setenv(EnvSudoersFile, wantSudoersFile) + t.Setenv(EnvVisudoBin, wantVisudo) + t.Setenv(EnvStatePath, wantStatePath) + t.Setenv(EnvCleanupInterval, wantCleanup) + t.Setenv(EnvRequestTimeout, wantRequestTimeout) + t.Setenv(EnvStateRetention, wantStateRetention) + t.Setenv(EnvSocketUser, wantSocketUser) + t.Setenv(EnvSocketGroup, wantSocketGroup) + t.Setenv(EnvLogLevel, wantLogLevel) + t.Setenv(EnvWindowsAdminGroup, wantWindowsAdminGroup) + + cfg, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv failed: %v", err) + } + + if cfg.SocketPath != wantSocket { + t.Fatalf("unexpected socket path: got %q want %q", cfg.SocketPath, wantSocket) + } + if cfg.SudoersDir != wantSudoers { + t.Fatalf("unexpected sudoers dir: got %q want %q", cfg.SudoersDir, wantSudoers) + } + if cfg.SudoersFile != wantSudoersFile { + t.Fatalf("unexpected sudoers file: got %q want %q", cfg.SudoersFile, wantSudoersFile) + } + if cfg.VisudoBin != wantVisudo { + t.Fatalf("unexpected visudo bin: got %q want %q", cfg.VisudoBin, wantVisudo) + } + if cfg.StatePath != wantStatePath { + t.Fatalf("unexpected state path: got %q want %q", cfg.StatePath, wantStatePath) + } + if cfg.CleanupInterval != 30*time.Second { + t.Fatalf("unexpected cleanup interval: got %s want %s", cfg.CleanupInterval, 30*time.Second) + } + if cfg.RequestTimeout != 5*time.Minute { + t.Fatalf("unexpected request timeout: got %s want %s", cfg.RequestTimeout, 5*time.Minute) + } + if cfg.StateRetention != 48*time.Hour { + t.Fatalf("unexpected state retention: got %s want %s", cfg.StateRetention, 48*time.Hour) + } + if cfg.SocketUser != wantSocketUser { + t.Fatalf("unexpected socket user: got %q want %q", cfg.SocketUser, wantSocketUser) + } + if cfg.SocketGroup != wantSocketGroup { + t.Fatalf("unexpected socket group: got %q want %q", cfg.SocketGroup, wantSocketGroup) + } + if cfg.LogLevel != wantLogLevel { + t.Fatalf("unexpected log level: got %q want %q", cfg.LogLevel, wantLogLevel) + } + if cfg.WindowsAdminGroup != wantWindowsAdminGroup { + t.Fatalf("unexpected windows admin group: got %q want %q", cfg.WindowsAdminGroup, wantWindowsAdminGroup) + } +} + +func TestLoadFromEnvRejectsInvalidCleanupInterval(t *testing.T) { + t.Setenv(EnvCleanupInterval, "not-a-duration") + if _, err := LoadFromEnv(); err == nil { + t.Fatal("expected cleanup interval parse error") + } +} + +func TestLoadFromEnvRejectsInvalidRequestTimeout(t *testing.T) { + t.Setenv(EnvRequestTimeout, "not-a-duration") + if _, err := LoadFromEnv(); err == nil { + t.Fatal("expected request timeout parse error") + } +} + +func TestLoadFromEnvRejectsInvalidStateRetention(t *testing.T) { + t.Setenv(EnvStateRetention, "not-a-duration") + if _, err := LoadFromEnv(); err == nil { + t.Fatal("expected state retention parse error") + } +} + +func TestValidateRejectsEmptySocketPath(t *testing.T) { + cfg := &Config{ + SocketPath: " ", + SudoersDir: "/etc/sudoers.d", + SudoersFile: "/etc/sudoers", + VisudoBin: "visudo", + StatePath: "/var/lib/thand/elevate/state.json", + CleanupInterval: time.Minute, + StateRetention: 24 * time.Hour, + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for empty socket path") + } +} + +func TestValidateRejectsEmptySudoersDir(t *testing.T) { + cfg := &Config{ + SocketPath: "/var/run/thand/elevate.sock", + SudoersDir: " ", + SudoersFile: "/etc/sudoers", + VisudoBin: "visudo", + StatePath: "/var/lib/thand/elevate/state.json", + CleanupInterval: time.Minute, + StateRetention: 24 * time.Hour, + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for empty sudoers dir") + } +} + +func TestValidateRejectsEmptySudoersFile(t *testing.T) { + cfg := &Config{ + SocketPath: "/var/run/thand/elevate.sock", + SudoersDir: "/etc/sudoers.d", + SudoersFile: " ", + VisudoBin: "visudo", + StatePath: "/var/lib/thand/elevate/state.json", + CleanupInterval: time.Minute, + StateRetention: 24 * time.Hour, + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for empty sudoers file") + } +} + +func TestValidateRejectsEmptyVisudoBin(t *testing.T) { + cfg := &Config{ + SocketPath: "/var/run/thand/elevate.sock", + SudoersDir: "/etc/sudoers.d", + SudoersFile: "/etc/sudoers", + VisudoBin: " ", + StatePath: "/var/lib/thand/elevate/state.json", + CleanupInterval: time.Minute, + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for empty visudo binary") + } +} + +func TestValidateRejectsEmptyStatePath(t *testing.T) { + cfg := &Config{ + SocketPath: "/var/run/thand/elevate.sock", + SudoersDir: "/etc/sudoers.d", + SudoersFile: "/etc/sudoers", + VisudoBin: "visudo", + StatePath: " ", + CleanupInterval: time.Minute, + StateRetention: 24 * time.Hour, + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for empty state path") + } +} + +func TestValidateRejectsNonPositiveCleanupInterval(t *testing.T) { + cfg := &Config{ + SocketPath: "/var/run/thand/elevate.sock", + SudoersDir: "/etc/sudoers.d", + SudoersFile: "/etc/sudoers", + VisudoBin: "visudo", + StatePath: "/var/lib/thand/elevate/state.json", + CleanupInterval: 0, + StateRetention: 24 * time.Hour, + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for cleanup interval") + } +} + +func TestValidateRejectsNonPositiveRequestTimeout(t *testing.T) { + cfg := &Config{ + SocketPath: "/var/run/thand/elevate.sock", + SudoersDir: "/etc/sudoers.d", + SudoersFile: "/etc/sudoers", + VisudoBin: "visudo", + StatePath: "/var/lib/thand/elevate/state.json", + CleanupInterval: time.Minute, + RequestTimeout: 0, + StateRetention: 24 * time.Hour, + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for request timeout") + } +} + +func TestValidateRejectsNonPositiveStateRetention(t *testing.T) { + cfg := &Config{ + SocketPath: "/var/run/thand/elevate.sock", + SudoersDir: "/etc/sudoers.d", + SudoersFile: "/etc/sudoers", + VisudoBin: "visudo", + StatePath: "/var/lib/thand/elevate/state.json", + CleanupInterval: time.Minute, + RequestTimeout: time.Second, + StateRetention: 0, + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for state retention") + } +} + +func TestValidateRejectsInvalidLogLevel(t *testing.T) { + cfg := &Config{ + SocketPath: "/var/run/thand/elevate.sock", + SudoersDir: "/etc/sudoers.d", + SudoersFile: "/etc/sudoers", + VisudoBin: "visudo", + StatePath: "/var/lib/thand/elevate/state.json", + CleanupInterval: time.Minute, + RequestTimeout: time.Second, + StateRetention: 24 * time.Hour, + LogLevel: "verbose", + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for log level") + } +} + +func TestValidateForWindowsDoesNotRequireLinuxSudoersFields(t *testing.T) { + cfg := &Config{ + SocketPath: `C:\ProgramData\Thand\elevate.sock`, + SudoersDir: " ", + SudoersFile: " ", + VisudoBin: " ", + StatePath: `C:\ProgramData\Thand\elevate\state.json`, + CleanupInterval: time.Minute, + RequestTimeout: time.Second, + StateRetention: 24 * time.Hour, + LogLevel: "info", + } + if err := cfg.validateForOS("windows"); err != nil { + t.Fatalf("unexpected validation error for windows config: %v", err) + } +} + +func TestValidateRejectsInvalidSocketUser(t *testing.T) { + cfg := &Config{ + SocketPath: "/var/run/thand/elevate.sock", + SudoersDir: "/etc/sudoers.d", + SudoersFile: "/etc/sudoers", + VisudoBin: "visudo", + StatePath: "/var/lib/thand/elevate/state.json", + CleanupInterval: time.Minute, + RequestTimeout: time.Second, + StateRetention: 24 * time.Hour, + LogLevel: "info", + SocketUser: "alice smith", + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for invalid socket user") + } +} + +func TestValidateRejectsInvalidSocketGroup(t *testing.T) { + cfg := &Config{ + SocketPath: "/var/run/thand/elevate.sock", + SudoersDir: "/etc/sudoers.d", + SudoersFile: "/etc/sudoers", + VisudoBin: "visudo", + StatePath: "/var/lib/thand/elevate/state.json", + CleanupInterval: time.Minute, + RequestTimeout: time.Second, + StateRetention: 24 * time.Hour, + LogLevel: "info", + SocketGroup: "thand/admins", + } + if err := cfg.Validate(); err == nil { + t.Fatal("expected validation error for invalid socket group") + } +} + +func TestValidateForWindowsRejectsInvalidAdminGroup(t *testing.T) { + cfg := &Config{ + SocketPath: `C:\ProgramData\Thand\elevate.sock`, + StatePath: `C:\ProgramData\Thand\elevate\state.json`, + CleanupInterval: time.Minute, + RequestTimeout: time.Second, + StateRetention: 24 * time.Hour, + LogLevel: "info", + WindowsAdminGroup: "Administrators; Remove-Item *", + } + if err := cfg.validateForOS("windows"); err == nil { + t.Fatal("expected validation error for invalid windows admin group") + } +} + +func TestParseLogLevel(t *testing.T) { + tests := []struct { + in string + wantErr bool + }{ + {in: "debug"}, + {in: "info"}, + {in: "warn"}, + {in: "warning"}, + {in: "error"}, + {in: ""}, + {in: "bad", wantErr: true}, + } + for _, tc := range tests { + _, err := ParseLogLevel(tc.in) + if tc.wantErr && err == nil { + t.Fatalf("expected error for %q", tc.in) + } + if !tc.wantErr && err != nil { + t.Fatalf("unexpected error for %q: %v", tc.in, err) + } + } +} diff --git a/cmd/elevate/domain/grant_state.go b/cmd/elevate/domain/grant_state.go new file mode 100644 index 00000000..0e52f6ca --- /dev/null +++ b/cmd/elevate/domain/grant_state.go @@ -0,0 +1,44 @@ +package domain + +import "time" + +// IsCompletedGrantState reports whether the persisted grant has already been +// transitioned into its completed/tombstone state. +func IsCompletedGrantState(grant GrantState) bool { + return !grant.CompletedAtWallUTC.IsZero() +} + +// IsActiveGrantState reports whether the persisted grant is still active and +// should block a new grant for the same user. +func IsActiveGrantState(grant GrantState, nowMonoNS int64, nowWallUTC time.Time) bool { + return !IsCompletedGrantState(grant) && !IsExpiredGrantState(grant, nowMonoNS, nowWallUTC) +} + +// IsExpiredGrantState reports whether a persisted grant has expired according to +// the helper's fail-secure wall-clock and monotonic-clock rules. +func IsExpiredGrantState(grant GrantState, nowMonoNS int64, nowWallUTC time.Time) bool { + switch { + case grant.DurationSeconds <= 0: + return true + case grant.GrantedAtMonoNS <= 0: + return true + case grant.GrantedAtWallUTC.IsZero(): + return true + case nowWallUTC.IsZero(): + return true + } + + durationNS := grant.DurationSeconds * int64(time.Second) + + wallExpiry := grant.GrantedAtWallUTC.Add(time.Duration(durationNS)) + if wallExpiry.Before(nowWallUTC) { + return true + } + + isMonoValid := nowMonoNS >= grant.GrantedAtMonoNS + if !isMonoValid { + return false + } + + return nowMonoNS-grant.GrantedAtMonoNS >= durationNS +} diff --git a/cmd/elevate/domain/types.go b/cmd/elevate/domain/types.go new file mode 100644 index 00000000..bfc9e9bc --- /dev/null +++ b/cmd/elevate/domain/types.go @@ -0,0 +1,78 @@ +package domain + +import "time" + +// Local helper protocol frame types. These are intentionally private to cmd/elevate. +const ( + FrameTypeRequest = "request" + FrameTypeChallenge = "challenge" + FrameTypeSignedResponse = "signed_response" + FrameTypeResult = "result" +) + +// Action is the requested elevation operation type. +type Action string + +const ( + ActionGrant Action = "grant" + ActionRevoke Action = "revoke" +) + +type RequestFrame struct { + Type string `json:"type"` + Action Action `json:"action"` + WorkflowID string `json:"workflow_id"` + RequestID string `json:"request_id"` + Username string `json:"username"` + DurationSeconds int64 `json:"duration_seconds,omitempty"` +} + +type ChallengeFrame struct { + Type string `json:"type"` + Nonce string `json:"nonce"` +} + +type SignedResponseFrame struct { + Type string `json:"type"` + KeyID string `json:"key_id"` + Signature string `json:"signature"` + SignedPayload string `json:"signed_payload"` +} + +type ResultFrame struct { + Type string `json:"type"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + RequestID string `json:"request_id,omitempty"` +} + +type GrantRequest struct { + RequestID string + WorkflowID string + Username string + DurationSeconds int64 +} + +type RevokeRequest struct { + RequestID string + WorkflowID string + Username string +} + +type GrantResult struct { + RequestID string + Username string + Expiry time.Time + WasAlreadyPrivileged bool +} + +type GrantState struct { + RequestID string `json:"request_id"` + WorkflowID string `json:"workflow_id"` + Username string `json:"username"` + GrantedAtWallUTC time.Time `json:"granted_at_wall_utc"` + GrantedAtMonoNS int64 `json:"granted_at_mono_ns"` + DurationSeconds int64 `json:"duration_seconds"` + WasAlreadyPrivileged bool `json:"was_already_privileged"` + CompletedAtWallUTC time.Time `json:"completed_at_wall_utc,omitempty"` +} diff --git a/cmd/elevate/go.mod b/cmd/elevate/go.mod new file mode 100644 index 00000000..f45fb751 --- /dev/null +++ b/cmd/elevate/go.mod @@ -0,0 +1,7 @@ +module github.com/thand-io/agent/cmd/elevate + +go 1.25 + +toolchain go1.25.0 + +require golang.org/x/sys v0.41.0 diff --git a/cmd/elevate/go.sum b/cmd/elevate/go.sum new file mode 100644 index 00000000..cdc9b1ce --- /dev/null +++ b/cmd/elevate/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= diff --git a/cmd/elevate/grant/linux_engine.go b/cmd/elevate/grant/linux_engine.go new file mode 100644 index 00000000..3115d197 --- /dev/null +++ b/cmd/elevate/grant/linux_engine.go @@ -0,0 +1,269 @@ +package grant + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" +) + +const ( + sudoersFileMode = 0o440 +) + +var ( + // ErrInvalidGrantRequest indicates malformed or unsafe grant input. + ErrInvalidGrantRequest = errors.New("invalid grant request") + // ErrInvalidRevokeRequest indicates malformed or unsafe revoke input. + ErrInvalidRevokeRequest = errors.New("invalid revoke request") + + requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) + usernamePattern = regexp.MustCompile(`^[a-z_][a-z0-9_-]*[$]?$`) +) + +// EngineOption configures a LinuxEngine instance. +type EngineOption func(*LinuxEngine) + +// WithNow overrides the wall clock source. +func WithNow(fn func() time.Time) EngineOption { + return func(e *LinuxEngine) { + e.now = fn + } +} + +// WithValidateFile overrides sudoers validation for tests. +func WithValidateFile(fn func(context.Context, string) error) EngineOption { + return func(e *LinuxEngine) { + e.validateFile = fn + } +} + +// WithWriteFile overrides file write behavior for tests. +func WithWriteFile(fn func(name string, data []byte, perm os.FileMode) error) EngineOption { + return func(e *LinuxEngine) { + e.writeFile = fn + } +} + +// WithCheckAlreadyPrivileged provides baseline membership hook; persistence is in state layer. +func WithCheckAlreadyPrivileged(fn func(context.Context, string) (bool, error)) EngineOption { + return func(e *LinuxEngine) { + e.checkAlreadyPrivileged = fn + } +} + +// LinuxEngine implements local-admin grant/revoke via sudoers files. +type LinuxEngine struct { + sudoersDir string + sudoersFile string + visudoBin string + now func() time.Time + + validateFile func(context.Context, string) error + writeFile func(name string, data []byte, perm os.FileMode) error + checkAlreadyPrivileged func(context.Context, string) (bool, error) +} + +// LinuxEngineConfig contains required Linux grant engine configuration. +type LinuxEngineConfig struct { + SudoersDir string + SudoersFile string + VisudoBin string +} + +// NewLinuxEngine constructs a Linux engine backed by sudoers.d files. +func NewLinuxEngine(cfg LinuxEngineConfig, opts ...EngineOption) (*LinuxEngine, error) { + e := &LinuxEngine{ + sudoersDir: strings.TrimSpace(cfg.SudoersDir), + sudoersFile: strings.TrimSpace(cfg.SudoersFile), + visudoBin: strings.TrimSpace(cfg.VisudoBin), + now: func() time.Time { return time.Now().UTC() }, + writeFile: os.WriteFile, + checkAlreadyPrivileged: func(ctx context.Context, username string) (bool, error) { + _ = ctx + _ = username + // Hook only in this chunk; actual baseline persistence happens in state layer. + return false, nil + }, + } + + for _, opt := range opts { + opt(e) + } + if e.sudoersDir == "" { + return nil, errors.New("sudoers dir is required") + } + if e.sudoersFile == "" { + return nil, errors.New("sudoers file is required") + } + if e.visudoBin == "" { + return nil, errors.New("visudo binary is required") + } + + // Ensure validateFile uses configured visudo binary unless explicitly overridden. + if e.validateFile == nil { + e.validateFile = func(ctx context.Context, path string) error { + cmd := exec.CommandContext(ctx, e.visudoBin, "-cf", path) + output, err := cmd.CombinedOutput() + if err != nil { + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return fmt.Errorf("visudo validation failed: %w", err) + } + return fmt.Errorf("visudo validation failed: %s: %w", trimmed, err) + } + return nil + } + } + + return e, nil +} + +// Grant validates input, checks sudoers preconditions, writes a temporary sudoers +// rule, validates it with visudo, and returns the resulting expiry metadata. +func (e *LinuxEngine) Grant(ctx context.Context, req domain.GrantRequest) (domain.GrantResult, error) { + if !isValidRequestID(req.RequestID) || !isValidUsername(req.Username) { + return domain.GrantResult{}, ErrInvalidGrantRequest + } + if req.DurationSeconds <= 0 { + return domain.GrantResult{}, ErrInvalidGrantRequest + } + + if err := e.ensureSudoersDirExists(); err != nil { + return domain.GrantResult{}, err + } + if err := e.ensureSudoersIncludeDir(); err != nil { + return domain.GrantResult{}, err + } + + alreadyPrivileged, err := e.checkAlreadyPrivileged(ctx, req.Username) + if err != nil { + return domain.GrantResult{}, fmt.Errorf("check baseline privilege: %w", err) + } + if alreadyPrivileged { + return domain.GrantResult{ + RequestID: req.RequestID, + Username: req.Username, + Expiry: e.now().Add(time.Duration(req.DurationSeconds) * time.Second), + WasAlreadyPrivileged: true, + }, nil + } + + sudoersPath := e.sudoersPath(req.RequestID) + tmpPath := sudoersPath + ".tmp" + if err := e.writeFile(tmpPath, []byte(e.sudoersContent(req)), 0o600); err != nil { + return domain.GrantResult{}, fmt.Errorf("write sudoers temp file: %w", err) + } + + // Validate before activation so an invalid rule never becomes live in sudoers.d. + if err := e.validateFile(ctx, tmpPath); err != nil { + _ = os.Remove(tmpPath) + return domain.GrantResult{}, err + } + + if err := os.Chmod(tmpPath, sudoersFileMode); err != nil { + _ = os.Remove(tmpPath) + return domain.GrantResult{}, fmt.Errorf("set sudoers permissions: %w", err) + } + + if err := os.Rename(tmpPath, sudoersPath); err != nil { + _ = os.Remove(tmpPath) + return domain.GrantResult{}, fmt.Errorf("activate sudoers file: %w", err) + } + + return domain.GrantResult{ + RequestID: req.RequestID, + Username: req.Username, + Expiry: e.now().Add(time.Duration(req.DurationSeconds) * time.Second), + WasAlreadyPrivileged: alreadyPrivileged, + }, nil +} + +// Revoke removes the sudoers rule for a request and is idempotent when the +// target file does not exist. +func (e *LinuxEngine) Revoke(ctx context.Context, req domain.RevokeRequest) error { + _ = ctx + if !isValidRequestID(req.RequestID) { + return ErrInvalidRevokeRequest + } + + sudoersPath := e.sudoersPath(req.RequestID) + if err := os.Remove(sudoersPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("remove sudoers file: %w", err) + } + + return nil +} + +func (e *LinuxEngine) sudoersPath(requestID string) string { + return filepath.Join(e.sudoersDir, "thand-"+requestID) +} + +func (e *LinuxEngine) sudoersContent(req domain.GrantRequest) string { + expires := e.now().Add(time.Duration(req.DurationSeconds) * time.Second).Format(time.RFC3339) + return fmt.Sprintf("# thand-agent temporary elevation\n# request_id: %s\n# expires: %s\n%s ALL=(ALL:ALL) NOPASSWD: ALL\n", + req.RequestID, + expires, + req.Username, + ) +} + +func isValidRequestID(v string) bool { + return requestIDPattern.MatchString(v) +} + +func isValidUsername(v string) bool { + return usernamePattern.MatchString(v) +} + +func (e *LinuxEngine) ensureSudoersDirExists() error { + info, err := os.Stat(e.sudoersDir) + if err != nil { + return fmt.Errorf("sudoers dir is not accessible %q: %w", e.sudoersDir, err) + } + if !info.IsDir() { + return fmt.Errorf("sudoers dir is not a directory: %q", e.sudoersDir) + } + return nil +} + +func (e *LinuxEngine) ensureSudoersIncludeDir() error { + content, err := os.ReadFile(e.sudoersFile) + if err != nil { + return fmt.Errorf("read sudoers file %q: %w", e.sudoersFile, err) + } + + if !hasIncludedir(string(content), e.sudoersDir) { + return fmt.Errorf("sudoers file %q missing includedir for %q", e.sudoersFile, e.sudoersDir) + } + + return nil +} + +func hasIncludedir(content string, sudoersDir string) bool { + for _, line := range strings.Split(content, "\n") { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "#includedir") && !strings.HasPrefix(trimmed, "@includedir") { + continue + } + fields := strings.Fields(trimmed) + if len(fields) < 2 { + continue + } + included := strings.Trim(fields[1], `"'`) + if included == sudoersDir { + return true + } + } + return false +} diff --git a/cmd/elevate/grant/linux_engine_test.go b/cmd/elevate/grant/linux_engine_test.go new file mode 100644 index 00000000..2ebd9f09 --- /dev/null +++ b/cmd/elevate/grant/linux_engine_test.go @@ -0,0 +1,273 @@ +package grant + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" +) + +func TestGrantWritesAndRevokesSudoersFile(t *testing.T) { + now := time.Date(2026, 2, 22, 12, 0, 0, 0, time.UTC) + dir := t.TempDir() + sudoersFile := writeTempSudoersFile(t, "#includedir "+dir+"\n") + + engine := mustNewEngine(t, LinuxEngineConfig{ + SudoersDir: dir, + SudoersFile: sudoersFile, + VisudoBin: "visudo", + }, + WithNow(func() time.Time { return now }), + WithValidateFile(func(ctx context.Context, path string) error { + _ = ctx + if _, err := os.Stat(path); err != nil { + return err + } + return nil + }), + ) + + res, err := engine.Grant(context.Background(), domain.GrantRequest{ + RequestID: "abc-123", + WorkflowID: "wf-1", + Username: "alice", + DurationSeconds: 600, + }) + if err != nil { + t.Fatalf("Grant failed: %v", err) + } + + if res.RequestID != "abc-123" || res.Username != "alice" { + t.Fatalf("unexpected grant result: %+v", res) + } + if !res.Expiry.Equal(now.Add(600 * time.Second)) { + t.Fatalf("unexpected expiry: got %s want %s", res.Expiry, now.Add(600*time.Second)) + } + + path := filepath.Join(dir, "thand-abc-123") + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read sudoers file failed: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat sudoers file failed: %v", err) + } + if got := info.Mode().Perm(); got != sudoersFileMode { + t.Fatalf("unexpected sudoers file mode: got %o want %o", got, sudoersFileMode) + } + text := string(b) + if !strings.Contains(text, "request_id: abc-123") || !strings.Contains(text, "alice ALL=(ALL:ALL) NOPASSWD: ALL") { + t.Fatalf("unexpected sudoers content:\n%s", text) + } + + if err := engine.Revoke(context.Background(), domain.RevokeRequest{RequestID: "abc-123"}); err != nil { + t.Fatalf("Revoke failed: %v", err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected removed sudoers file, stat err=%v", err) + } +} + +func TestGrantValidationFailureRollsBackFile(t *testing.T) { + dir := t.TempDir() + sudoersFile := writeTempSudoersFile(t, "#includedir "+dir+"\n") + engine := mustNewEngine(t, LinuxEngineConfig{ + SudoersDir: dir, + SudoersFile: sudoersFile, + VisudoBin: "visudo", + }, + WithValidateFile(func(ctx context.Context, path string) error { + _ = ctx + _ = path + return errors.New("invalid sudoers") + }), + ) + + _, err := engine.Grant(context.Background(), domain.GrantRequest{ + RequestID: "bad", + Username: "bob", + DurationSeconds: 60, + }) + if err == nil { + t.Fatal("expected Grant to fail") + } + + if _, statErr := os.Stat(filepath.Join(dir, "thand-bad")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("expected sudoers file rollback, got stat err=%v", statErr) + } +} + +func TestGrantInvalidRequest(t *testing.T) { + engine := mustNewEngine(t, LinuxEngineConfig{ + SudoersDir: t.TempDir(), + SudoersFile: writeTempSudoersFile(t, "#includedir /tmp/unused\n"), + VisudoBin: "visudo", + }) + + cases := []domain.GrantRequest{ + {RequestID: "", Username: "alice", DurationSeconds: 10}, + {RequestID: "bad/id", Username: "alice", DurationSeconds: 10}, + {RequestID: "bad\nid", Username: "alice", DurationSeconds: 10}, + {RequestID: " req", Username: "alice", DurationSeconds: 10}, + {RequestID: "req ", Username: "alice", DurationSeconds: 10}, + {RequestID: "x", Username: "", DurationSeconds: 10}, + {RequestID: "x", Username: "bad user", DurationSeconds: 10}, + {RequestID: "x", Username: "bad\nuser", DurationSeconds: 10}, + {RequestID: "x", Username: " alice", DurationSeconds: 10}, + {RequestID: "x", Username: "alice ", DurationSeconds: 10}, + {RequestID: "x", Username: "alice", DurationSeconds: 0}, + } + for _, c := range cases { + if _, err := engine.Grant(context.Background(), c); !errors.Is(err, ErrInvalidGrantRequest) { + t.Fatalf("expected ErrInvalidGrantRequest for %+v, got %v", c, err) + } + } +} + +func TestRevokeMissingIsIdempotent(t *testing.T) { + engine := mustNewEngine(t, LinuxEngineConfig{ + SudoersDir: t.TempDir(), + SudoersFile: writeTempSudoersFile(t, "#includedir /tmp/unused\n"), + VisudoBin: "visudo", + }) + if err := engine.Revoke(context.Background(), domain.RevokeRequest{RequestID: "missing"}); err != nil { + t.Fatalf("expected nil on missing revoke, got %v", err) + } +} + +func TestRevokeInvalidRequestID(t *testing.T) { + engine := mustNewEngine(t, LinuxEngineConfig{ + SudoersDir: t.TempDir(), + SudoersFile: writeTempSudoersFile(t, "#includedir /tmp/unused\n"), + VisudoBin: "visudo", + }) + if err := engine.Revoke(context.Background(), domain.RevokeRequest{RequestID: "bad/id"}); !errors.Is(err, ErrInvalidRevokeRequest) { + t.Fatalf("expected ErrInvalidRevokeRequest, got %v", err) + } +} + +func TestBaselinePrivilegeHook(t *testing.T) { + dir := t.TempDir() + sudoersFile := writeTempSudoersFile(t, "#includedir "+dir+"\n") + engine := mustNewEngine(t, LinuxEngineConfig{ + SudoersDir: dir, + SudoersFile: sudoersFile, + VisudoBin: "visudo", + }, + WithValidateFile(func(ctx context.Context, path string) error { _ = ctx; _ = path; return nil }), + WithCheckAlreadyPrivileged(func(ctx context.Context, username string) (bool, error) { + _ = ctx + _ = username + return true, nil + }), + ) + + res, err := engine.Grant(context.Background(), domain.GrantRequest{RequestID: "r1", Username: "alice", DurationSeconds: 30}) + if err != nil { + t.Fatalf("Grant failed: %v", err) + } + if !res.WasAlreadyPrivileged { + t.Fatal("expected WasAlreadyPrivileged=true from hook") + } + if _, err := os.Stat(filepath.Join(dir, "thand-r1")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("expected no sudoers file when user already has baseline privilege, got err=%v", err) + } +} + +func TestGrantFailsWhenSudoersDirMissing(t *testing.T) { + dir := filepath.Join(t.TempDir(), "missing") + sudoersFile := writeTempSudoersFile(t, "#includedir "+dir+"\n") + engine := mustNewEngine(t, LinuxEngineConfig{ + SudoersDir: dir, + SudoersFile: sudoersFile, + VisudoBin: "visudo", + }) + + _, err := engine.Grant(context.Background(), domain.GrantRequest{ + RequestID: "r1", + Username: "alice", + DurationSeconds: 60, + }) + if err == nil || !strings.Contains(err.Error(), "sudoers dir is not accessible") { + t.Fatalf("expected missing dir error, got %v", err) + } +} + +func TestGrantFailsWhenSudoersMissingIncludedir(t *testing.T) { + dir := t.TempDir() + sudoersFile := writeTempSudoersFile(t, "#includedir /some/other/dir\n") + engine := mustNewEngine(t, LinuxEngineConfig{ + SudoersDir: dir, + SudoersFile: sudoersFile, + VisudoBin: "visudo", + }) + + _, err := engine.Grant(context.Background(), domain.GrantRequest{ + RequestID: "r1", + Username: "alice", + DurationSeconds: 60, + }) + if err == nil || !strings.Contains(err.Error(), "missing includedir") { + t.Fatalf("expected missing include error, got %v", err) + } +} + +func TestGrantAcceptsAtIncludedir(t *testing.T) { + dir := t.TempDir() + sudoersFile := writeTempSudoersFile(t, "@includedir "+dir+"\n") + + engine := mustNewEngine(t, LinuxEngineConfig{ + SudoersDir: dir, + SudoersFile: sudoersFile, + VisudoBin: "visudo", + }, + WithValidateFile(func(ctx context.Context, path string) error { + _ = ctx + if _, err := os.Stat(path); err != nil { + return err + } + return nil + }), + ) + + _, err := engine.Grant(context.Background(), domain.GrantRequest{ + RequestID: "abc-123", + WorkflowID: "wf-1", + Username: "alice", + DurationSeconds: 60, + }) + if err != nil { + t.Fatalf("Grant failed with @includedir: %v", err) + } +} + +func writeTempSudoersFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "sudoers") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp sudoers file: %v", err) + } + return path +} + +func mustNewEngine(t *testing.T, cfg LinuxEngineConfig, opts ...EngineOption) *LinuxEngine { + t.Helper() + engine, err := NewLinuxEngine(cfg, opts...) + if err != nil { + t.Fatalf("NewLinuxEngine failed: %v", err) + } + return engine +} + +func TestNewLinuxEngineRequiresConfigFields(t *testing.T) { + _, err := NewLinuxEngine(LinuxEngineConfig{}) + if err == nil { + t.Fatal("expected error for empty config") + } +} diff --git a/cmd/elevate/grant/windows_engine.go b/cmd/elevate/grant/windows_engine.go new file mode 100644 index 00000000..f490d1d7 --- /dev/null +++ b/cmd/elevate/grant/windows_engine.go @@ -0,0 +1,224 @@ +package grant + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" + "github.com/thand-io/agent/cmd/elevate/identity" +) + +const defaultWindowsAdminGroup = "Administrators" + +const ( + windowsMembershipScript = `& { +param($group) +ConvertTo-Json -InputObject @( +Get-LocalGroupMember -Group $group | + Select-Object Name,ObjectClass +) -Compress +} ` + windowsAddMemberScript = `& { +param($group, $member) +Add-LocalGroupMember -Group $group -Member $member +} ` + windowsRemoveMemberScript = `& { +param($group, $member) +Remove-LocalGroupMember -Group $group -Member $member +} ` +) + +type windowsLocalGroupMember struct { + Name string `json:"Name"` + ObjectClass string `json:"ObjectClass"` +} + +// WindowsEngineOption configures a WindowsEngine instance. +type WindowsEngineOption func(*WindowsEngine) + +// WithWindowsNow overrides the wall clock source. +func WithWindowsNow(fn func() time.Time) WindowsEngineOption { + return func(e *WindowsEngine) { + e.now = fn + } +} + +// WithWindowsRunCommand overrides command execution for tests. +func WithWindowsRunCommand(fn func(context.Context, string, ...string) ([]byte, error)) WindowsEngineOption { + return func(e *WindowsEngine) { + e.runCommand = fn + } +} + +// WithWindowsComputerName overrides the local computer name used for exact local-principal matching. +func WithWindowsComputerName(name string) WindowsEngineOption { + return func(e *WindowsEngine) { + e.computerName = strings.TrimSpace(name) + } +} + +// WindowsEngineConfig contains Windows grant engine configuration. +type WindowsEngineConfig struct { + AdminGroup string +} + +// WindowsEngine implements local-admin grant/revoke via local Administrators group membership. +type WindowsEngine struct { + adminGroup string + computerName string + now func() time.Time + runCommand func(context.Context, string, ...string) ([]byte, error) + isMember func(context.Context, string) (bool, error) +} + +// NewWindowsEngine constructs a Windows engine backed by PowerShell local-group cmdlets. +func NewWindowsEngine(cfg WindowsEngineConfig, opts ...WindowsEngineOption) (*WindowsEngine, error) { + adminGroup := strings.TrimSpace(cfg.AdminGroup) + if adminGroup == "" { + adminGroup = defaultWindowsAdminGroup + } + if !identity.ValidWindowsAdminGroup(adminGroup) { + return nil, errors.New("windows admin group is invalid") + } + + computerName := strings.TrimSpace(os.Getenv("COMPUTERNAME")) + if computerName == "" { + var err error + computerName, err = os.Hostname() + if err != nil { + return nil, fmt.Errorf("resolve windows computer name: %w", err) + } + computerName = strings.TrimSpace(computerName) + } + if computerName == "" { + return nil, errors.New("windows computer name is required") + } + + e := &WindowsEngine{ + adminGroup: adminGroup, + computerName: computerName, + now: func() time.Time { return time.Now().UTC() }, + runCommand: func(ctx context.Context, name string, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, name, args...) + return cmd.CombinedOutput() + }, + } + e.isMember = e.checkMembership + + for _, opt := range opts { + opt(e) + } + + return e, nil +} + +// Grant adds the user to the configured local admin group. +func (e *WindowsEngine) Grant(ctx context.Context, req domain.GrantRequest) (domain.GrantResult, error) { + if !isValidRequestID(req.RequestID) || !isValidWindowsUsername(req.Username) || req.DurationSeconds <= 0 { + return domain.GrantResult{}, ErrInvalidGrantRequest + } + + alreadyMember, err := e.isMember(ctx, req.Username) + if err != nil { + return domain.GrantResult{}, err + } + if !alreadyMember { + if _, err := e.runPowerShell(ctx, windowsAddMemberScript, e.adminGroup, req.Username); err != nil { + // If membership changed between check and add, treat as already privileged. + raceMember, raceErr := e.isMember(ctx, req.Username) + if raceErr != nil { + return domain.GrantResult{}, fmt.Errorf("add windows admin group member: %w", err) + } + if !raceMember { + return domain.GrantResult{}, fmt.Errorf("add windows admin group member: %w", err) + } + alreadyMember = true + } + } + + return domain.GrantResult{ + RequestID: req.RequestID, + Username: req.Username, + Expiry: e.now().Add(time.Duration(req.DurationSeconds) * time.Second), + WasAlreadyPrivileged: alreadyMember, + }, nil +} + +// Revoke removes the user from the configured local admin group. +func (e *WindowsEngine) Revoke(ctx context.Context, req domain.RevokeRequest) error { + if !isValidRequestID(req.RequestID) || !isValidWindowsUsername(req.Username) { + return ErrInvalidRevokeRequest + } + + member, err := e.isMember(ctx, req.Username) + if err != nil { + return err + } + if !member { + return nil + } + + if _, err := e.runPowerShell(ctx, windowsRemoveMemberScript, e.adminGroup, req.Username); err != nil { + memberAfter, checkErr := e.isMember(ctx, req.Username) + if checkErr != nil { + return fmt.Errorf("remove windows admin group member: %w", err) + } + if memberAfter { + return fmt.Errorf("remove windows admin group member: %w", err) + } + } + return nil +} + +func (e *WindowsEngine) checkMembership(ctx context.Context, username string) (bool, error) { + out, err := e.runPowerShell(ctx, windowsMembershipScript, e.adminGroup) + if err != nil { + return false, fmt.Errorf("list windows admin group members: %w", err) + } + + var members []windowsLocalGroupMember + if len(strings.TrimSpace(string(out))) == 0 { + return false, nil + } + if err := json.Unmarshal(out, &members); err != nil { + return false, fmt.Errorf("decode windows admin group members: %w", err) + } + + for _, member := range members { + if windowsUsernameMatches(member.Name, username, e.computerName) { + return true, nil + } + } + return false, nil +} + +func (e *WindowsEngine) runPowerShell(ctx context.Context, script string, args ...string) ([]byte, error) { + psArgs := []string{"-NoProfile", "-NonInteractive", "-Command", script} + psArgs = append(psArgs, args...) + return e.runCommand(ctx, "powershell", psArgs...) +} + +func isValidWindowsUsername(v string) bool { + return identity.ValidAccountName(v) +} + +func windowsUsernameMatches(line, username string, computerName string) bool { + lhs := strings.ToLower(strings.TrimSpace(line)) + rhs := strings.ToLower(strings.TrimSpace(username)) + localHost := strings.ToLower(strings.TrimSpace(computerName)) + if lhs == rhs { + return true + } + + prefix, suffix, ok := strings.Cut(lhs, `\`) + if ok { + return prefix == localHost && suffix == rhs + } + return false +} diff --git a/cmd/elevate/grant/windows_engine_test.go b/cmd/elevate/grant/windows_engine_test.go new file mode 100644 index 00000000..9448e725 --- /dev/null +++ b/cmd/elevate/grant/windows_engine_test.go @@ -0,0 +1,270 @@ +package grant + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" +) + +func TestWindowsGrantAddsUser(t *testing.T) { + now := time.Date(2026, 2, 23, 20, 0, 0, 0, time.UTC) + var addCalled bool + + engine, err := NewWindowsEngine(WindowsEngineConfig{}, + WithWindowsNow(func() time.Time { return now }), + WithWindowsComputerName("TESTDEV"), + WithWindowsRunCommand(func(ctx context.Context, name string, args ...string) ([]byte, error) { + _ = ctx + if name != "powershell" { + t.Fatalf("unexpected command name %q", name) + } + if len(args) < 5 { + t.Fatalf("unexpected command args: %+v", args) + } + switch args[3] { + case windowsMembershipScript: + if args[4] != "Administrators" { + t.Fatalf("unexpected membership group arg: %q", args[4]) + } + return []byte("[]"), nil + case windowsAddMemberScript: + if args[4] != "Administrators" || args[5] != "alice" { + t.Fatalf("unexpected add args: %+v", args) + } + addCalled = true + return []byte("ok"), nil + default: + t.Fatalf("unexpected script: %q", args[3]) + return nil, nil + } + }), + ) + if err != nil { + t.Fatalf("NewWindowsEngine failed: %v", err) + } + res, err := engine.Grant(context.Background(), domain.GrantRequest{ + RequestID: "req-1", + Username: "alice", + DurationSeconds: 600, + }) + if err != nil { + t.Fatalf("Grant failed: %v", err) + } + if !addCalled { + t.Fatal("expected add command to be called") + } + if res.WasAlreadyPrivileged { + t.Fatal("expected WasAlreadyPrivileged=false") + } + if !res.Expiry.Equal(now.Add(600 * time.Second)) { + t.Fatalf("unexpected expiry: got %s", res.Expiry) + } +} + +func TestWindowsGrantAlreadyMember(t *testing.T) { + engine, err := NewWindowsEngine(WindowsEngineConfig{}, + WithWindowsComputerName("TESTDEV"), + WithWindowsRunCommand(func(ctx context.Context, name string, args ...string) ([]byte, error) { + _ = ctx + if name != "powershell" { + t.Fatalf("unexpected command name %q", name) + } + if args[3] != windowsMembershipScript { + t.Fatalf("unexpected script: %q", args[3]) + } + return []byte(`[{"Name":"BUILTIN\\Administrators","ObjectClass":"Group"},{"Name":"alice","ObjectClass":"User"}]`), nil + }), + ) + if err != nil { + t.Fatalf("NewWindowsEngine failed: %v", err) + } + res, err := engine.Grant(context.Background(), domain.GrantRequest{ + RequestID: "req-1", + Username: "alice", + DurationSeconds: 60, + }) + if err != nil { + t.Fatalf("Grant failed: %v", err) + } + if !res.WasAlreadyPrivileged { + t.Fatal("expected WasAlreadyPrivileged=true") + } +} + +func TestWindowsGrantAlreadyMemberWithSingleJSONObjectInArray(t *testing.T) { + engine, err := NewWindowsEngine(WindowsEngineConfig{}, + WithWindowsComputerName("TESTDEV"), + WithWindowsRunCommand(func(ctx context.Context, name string, args ...string) ([]byte, error) { + _ = ctx + if name != "powershell" { + t.Fatalf("unexpected command name %q", name) + } + if args[3] != windowsMembershipScript { + t.Fatalf("unexpected script: %q", args[3]) + } + return []byte(`[{"Name":"alice","ObjectClass":"User"}]`), nil + }), + ) + if err != nil { + t.Fatalf("NewWindowsEngine failed: %v", err) + } + res, err := engine.Grant(context.Background(), domain.GrantRequest{ + RequestID: "req-1", + Username: "alice", + DurationSeconds: 60, + }) + if err != nil { + t.Fatalf("Grant failed: %v", err) + } + if !res.WasAlreadyPrivileged { + t.Fatal("expected WasAlreadyPrivileged=true") + } +} + +func TestWindowsRevokeIdempotentWhenMissing(t *testing.T) { + engine, err := NewWindowsEngine(WindowsEngineConfig{}, + WithWindowsComputerName("TESTDEV"), + WithWindowsRunCommand(func(ctx context.Context, name string, args ...string) ([]byte, error) { + _ = ctx + if name != "powershell" { + t.Fatalf("unexpected command name %q", name) + } + if args[3] != windowsMembershipScript { + t.Fatalf("unexpected script: %q", args[3]) + } + return []byte(`[{"Name":"Administrator","ObjectClass":"User"}]`), nil + }), + ) + if err != nil { + t.Fatalf("NewWindowsEngine failed: %v", err) + } + if err := engine.Revoke(context.Background(), domain.RevokeRequest{ + RequestID: "req-1", + Username: "alice", + }); err != nil { + t.Fatalf("Revoke failed: %v", err) + } +} + +func TestWindowsRevokeRemovesUser(t *testing.T) { + var deleted bool + call := 0 + + engine, err := NewWindowsEngine(WindowsEngineConfig{}, + WithWindowsComputerName("TESTDEV"), + WithWindowsRunCommand(func(ctx context.Context, name string, args ...string) ([]byte, error) { + _ = ctx + if name != "powershell" { + t.Fatalf("unexpected command name: %q", name) + } + call++ + switch call { + case 1: + if args[3] != windowsRemoveMemberScript { + t.Fatalf("unexpected script: %q", args[3]) + } + if args[4] != "Administrators" || args[5] != "alice" { + t.Fatalf("unexpected delete args: %+v", args) + } + deleted = true + return []byte("ok"), nil + default: + t.Fatalf("unexpected extra command: %+v", args) + return nil, nil + } + }), + ) + if err != nil { + t.Fatalf("NewWindowsEngine failed: %v", err) + } + engine.isMember = func(context.Context, string) (bool, error) { return true, nil } + + if err := engine.Revoke(context.Background(), domain.RevokeRequest{ + RequestID: "req-1", + Username: "alice", + }); err != nil { + t.Fatalf("Revoke failed: %v", err) + } + if !deleted { + t.Fatal("expected delete command to be called") + } +} + +func TestWindowsGrantInvalidRequest(t *testing.T) { + engine, err := NewWindowsEngine(WindowsEngineConfig{}, WithWindowsComputerName("TESTDEV")) + if err != nil { + t.Fatalf("NewWindowsEngine failed: %v", err) + } + + _, err = engine.Grant(context.Background(), domain.GrantRequest{ + RequestID: "", + Username: "alice", + DurationSeconds: 30, + }) + if !errors.Is(err, ErrInvalidGrantRequest) { + t.Fatalf("expected ErrInvalidGrantRequest, got %v", err) + } +} + +func TestNewWindowsEngineRejectsInvalidAdminGroup(t *testing.T) { + _, err := NewWindowsEngine(WindowsEngineConfig{AdminGroup: "Administrators; Remove-Item *"}, WithWindowsComputerName("TESTDEV")) + if err == nil { + t.Fatal("expected invalid admin group error") + } +} + +func TestWindowsUsernameMatchesLocalPrincipalExactly(t *testing.T) { + if !windowsUsernameMatches(`TESTDEV\alice`, "alice", "TESTDEV") { + t.Fatal("expected local machine-qualified principal to match") + } + if windowsUsernameMatches(`DOMAIN\alice`, "alice", "TESTDEV") { + t.Fatal("expected domain principal not to match local username") + } + if windowsUsernameMatches(`TESTDEV\bob`, "alice", "TESTDEV") { + t.Fatal("expected different local username not to match") + } +} + +func TestWindowsGrantDomainPrincipalDoesNotCountAsAlreadyMember(t *testing.T) { + var addCalled bool + + engine, err := NewWindowsEngine(WindowsEngineConfig{}, + WithWindowsComputerName("TESTDEV"), + WithWindowsRunCommand(func(ctx context.Context, name string, args ...string) ([]byte, error) { + _ = ctx + if name != "powershell" { + t.Fatalf("unexpected command name %q", name) + } + switch args[3] { + case windowsMembershipScript: + return []byte(`[{"Name":"DOMAIN\\alice","ObjectClass":"User"}]`), nil + case windowsAddMemberScript: + addCalled = true + return []byte("ok"), nil + default: + t.Fatalf("unexpected script: %q", args[3]) + return nil, nil + } + }), + ) + if err != nil { + t.Fatalf("NewWindowsEngine failed: %v", err) + } + res, err := engine.Grant(context.Background(), domain.GrantRequest{ + RequestID: "req-1", + Username: "alice", + DurationSeconds: 60, + }) + if err != nil { + t.Fatalf("Grant failed: %v", err) + } + if !addCalled { + t.Fatal("expected add command to be called for local user when only domain user is present") + } + if res.WasAlreadyPrivileged { + t.Fatal("expected WasAlreadyPrivileged=false for domain principal mismatch") + } +} diff --git a/cmd/elevate/handler/auth.go b/cmd/elevate/handler/auth.go new file mode 100644 index 00000000..5d8a25f3 --- /dev/null +++ b/cmd/elevate/handler/auth.go @@ -0,0 +1,94 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/thand-io/agent/cmd/elevate/domain" + "github.com/thand-io/agent/cmd/elevate/verify" +) + +const ( + resultStatusOK = "ok" + resultStatusError = "error" +) + +// authenticateRequest performs the challenge-response signature flow: +// emit challenge nonce, read signed response, then verify payload+signature. +func (h *Handler) authenticateRequest(ctx context.Context, conn IPCConn, req domain.RequestFrame) *responseError { + nonce, err := h.generateNonce() + if err != nil { + return wrapInternal("generate challenge nonce", err) + } + + if err := h.writeFrame(ctx, conn, domain.ChallengeFrame{ + Type: domain.FrameTypeChallenge, + Nonce: nonce, + }); err != nil { + return wrapInternal("write challenge", err) + } + + signedBytes, err := conn.ReadFrame(ctx) + if err != nil { + return invalidRequestErr(fmt.Errorf("read signed response: %w", err)) + } + + var signed domain.SignedResponseFrame + if err := json.Unmarshal(signedBytes, &signed); err != nil { + return invalidRequestErr(fmt.Errorf("decode signed response: %w", err)) + } + if signed.Type != domain.FrameTypeSignedResponse { + return invalidRequestErr(fmt.Errorf("invalid signed response type: %s", signed.Type)) + } + + // Decode and validate signed payload fields against the original request+nonce. + payload, err := verify.DecodeSignedPayload(signed.SignedPayload) + if err != nil { + return invalidRequestErr(fmt.Errorf("decode signed payload: %w", err)) + } + if err := verify.MatchSignedPayload(req, payload, nonce); err != nil { + return unauthorizedErr(fmt.Errorf("match signed payload: %w", err)) + } + + canonical, err := verify.CanonicalPayload(payload) + if err != nil { + return invalidRequestErr(fmt.Errorf("canonical payload: %w", err)) + } + sig, err := verify.DecodeSignature(signed.Signature) + if err != nil { + return invalidRequestErr(fmt.Errorf("decode signature: %w", err)) + } + // Verify detached signature over canonical payload using pinned key ID. + if err := h.verifier.Verify(signed.KeyID, canonical, sig); err != nil { + return unauthorizedErr(fmt.Errorf("verify signature: %w", err)) + } + + return nil +} + +// writeResult sends the terminal result frame for the current request. +func (h *Handler) writeResult(ctx context.Context, conn IPCConn, req domain.RequestFrame, status string, code ErrorCode) error { + result := domain.ResultFrame{ + Type: domain.FrameTypeResult, + Status: status, + RequestID: req.RequestID, + } + if code != "" { + result.Error = string(code) + } + + return h.writeFrame(ctx, conn, result) +} + +// writeFrame marshals and writes one protocol frame to the connection. +func (h *Handler) writeFrame(ctx context.Context, conn IPCConn, frame any) error { + b, err := json.Marshal(frame) + if err != nil { + return fmt.Errorf("encode frame: %w", err) + } + if err := conn.WriteFrame(ctx, b); err != nil { + return fmt.Errorf("write frame: %w", err) + } + return nil +} diff --git a/cmd/elevate/handler/errors.go b/cmd/elevate/handler/errors.go new file mode 100644 index 00000000..255dec69 --- /dev/null +++ b/cmd/elevate/handler/errors.go @@ -0,0 +1,73 @@ +package handler + +import ( + "errors" + "fmt" + + "github.com/thand-io/agent/cmd/elevate/grant" +) + +// ErrorCode is a stable, client-facing error category. +type ErrorCode string + +const ( + ErrorCodeInvalidRequest ErrorCode = "invalid_request" + ErrorCodeUnauthorized ErrorCode = "unauthorized" + ErrorCodeInternal ErrorCode = "internal_error" + ErrorCodeRequestConflict ErrorCode = "request_conflict" + ErrorCodeActiveGrantExists ErrorCode = "active_grant_exists" +) + +type responseError struct { + Code ErrorCode + Cause error +} + +func (e *responseError) Error() string { + return string(e.Code) +} + +func (e *responseError) Unwrap() error { + return e.Cause +} + +func invalidRequestErr(err error) *responseError { + return &responseError{Code: ErrorCodeInvalidRequest, Cause: err} +} + +func unauthorizedErr(err error) *responseError { + return &responseError{Code: ErrorCodeUnauthorized, Cause: err} +} + +func internalErr(err error) *responseError { + return &responseError{Code: ErrorCodeInternal, Cause: err} +} + +func requestConflictErr(err error) *responseError { + return &responseError{Code: ErrorCodeRequestConflict, Cause: err} +} + +func activeGrantExistsErr(err error) *responseError { + return &responseError{Code: ErrorCodeActiveGrantExists, Cause: err} +} + +func wrapInternal(msg string, err error) *responseError { + return internalErr(fmt.Errorf("%s: %w", msg, err)) +} + +func classifyResponseError(err error) *responseError { + if err == nil { + return nil + } + + var resErr *responseError + if errors.As(err, &resErr) { + return resErr + } + switch { + case errors.Is(err, grant.ErrInvalidGrantRequest), errors.Is(err, grant.ErrInvalidRevokeRequest): + return invalidRequestErr(err) + default: + return internalErr(err) + } +} diff --git a/cmd/elevate/handler/handle_grant.go b/cmd/elevate/handler/handle_grant.go new file mode 100644 index 00000000..ce1ea6c3 --- /dev/null +++ b/cmd/elevate/handler/handle_grant.go @@ -0,0 +1,121 @@ +package handler + +import ( + "context" + "fmt" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" +) + +// handleGrant is split into its own file so grant flow can evolve independently. +func (h *Handler) handleGrant(ctx context.Context, conn IPCConn, req domain.RequestFrame) error { + if err := h.authenticateRequest(ctx, conn, req); err != nil { + return h.writeRequestError(ctx, conn, req, err) + } + if err := validateRequestUsername(req.Username); err != nil { + return h.writeRequestError(ctx, conn, req, err) + } + + grants, err := h.stateStore.List(ctx) + if err != nil { + return h.writeRequestError(ctx, conn, req, wrapInternal("list grant state", err)) + } + stateErr := validateGrantRequestState(grants, req, h.clock.NowMonoNS(), h.clock.NowWallUTC()) + switch { + case stateErr.idempotent: + return h.writeGrantSuccess(ctx, conn, req) + case stateErr == (grantStateError{}): + default: + return h.writeRequestError(ctx, conn, req, stateErr.responseError()) + } + + result, err := h.grantEngine.Grant(ctx, domain.GrantRequest{ + RequestID: req.RequestID, + WorkflowID: req.WorkflowID, + Username: req.Username, + DurationSeconds: req.DurationSeconds, + }) + if err != nil { + return h.writeRequestError(ctx, conn, req, fmt.Errorf("grant: %w", err)) + } + + state := domain.GrantState{ + RequestID: req.RequestID, + WorkflowID: req.WorkflowID, + Username: req.Username, + GrantedAtWallUTC: h.clock.NowWallUTC(), + GrantedAtMonoNS: h.clock.NowMonoNS(), + DurationSeconds: req.DurationSeconds, + WasAlreadyPrivileged: result.WasAlreadyPrivileged, + } + if err := h.stateStore.Put(ctx, state); err != nil { + return h.writeRequestError(ctx, conn, req, wrapInternal("persist grant state", err)) + } + + return h.writeGrantSuccess(ctx, conn, req) +} + +type grantStateError struct { + code ErrorCode + err error + idempotent bool +} + +func (e grantStateError) responseError() *responseError { + switch e.code { + case ErrorCodeRequestConflict: + return requestConflictErr(e.err) + case ErrorCodeActiveGrantExists: + return activeGrantExistsErr(e.err) + default: + return invalidRequestErr(e.err) + } +} + +func validateGrantRequestState(grants []domain.GrantState, req domain.RequestFrame, nowMonoNS int64, nowWallUTC time.Time) grantStateError { + // Resolve request-id semantics first so exact retries remain idempotent even if + // the same user also has another active grant in the state list. + for _, grant := range grants { + if grant.RequestID == req.RequestID { + if sameGrantRequest(grant, req) { + return grantStateError{idempotent: true} + } + return grantStateError{ + code: ErrorCodeRequestConflict, + err: fmt.Errorf("request %q conflicts with existing state", req.RequestID), + } + } + } + for _, grant := range grants { + if grant.Username == req.Username && domain.IsActiveGrantState(grant, nowMonoNS, nowWallUTC) { + return grantStateError{ + code: ErrorCodeActiveGrantExists, + err: fmt.Errorf("user %q already has an active grant", req.Username), + } + } + } + return grantStateError{} +} + +func sameGrantRequest(grant domain.GrantState, req domain.RequestFrame) bool { + return grant.WorkflowID == req.WorkflowID && + grant.Username == req.Username && + grant.DurationSeconds == req.DurationSeconds +} + +func (h *Handler) writeGrantSuccess(ctx context.Context, conn IPCConn, req domain.RequestFrame) error { + if err := h.writeResult(ctx, conn, req, resultStatusOK, ""); err != nil { + return err + } + h.logger.Info("access elevated for "+req.Username, + "component", "elevate_handler", + "action", string(req.Action), + "request_id", req.RequestID, + "workflow_id", req.WorkflowID, + "username", req.Username, + "duration_seconds", req.DurationSeconds, + "status", "ok", + ) + return nil +} diff --git a/cmd/elevate/handler/handle_grant_test.go b/cmd/elevate/handler/handle_grant_test.go new file mode 100644 index 00000000..3e51d012 --- /dev/null +++ b/cmd/elevate/handler/handle_grant_test.go @@ -0,0 +1,397 @@ +package handler + +import ( + "context" + "testing" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" + "github.com/thand-io/agent/cmd/elevate/grant" +) + +func TestHandleConnectionGrantSuccess(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionGrant, + WorkflowID: "wf-1", + RequestID: "req-1", + Username: "alice", + DurationSeconds: 60, + } + nonce := "fixed-nonce" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{grantResult: domain.GrantResult{WasAlreadyPrivileged: true}} + state := &stubStateStore{} + h := New(grantEngine, &stubVerifier{}, state, stubClock{ + mono: 123, + wall: time.Date(2026, 2, 22, 10, 0, 0, 0, time.UTC), + }) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.grantCalls != 1 { + t.Fatalf("expected 1 grant call, got %d", grantEngine.grantCalls) + } + if state.putCalls != 1 { + t.Fatalf("expected 1 state put, got %d", state.putCalls) + } + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusOK) +} + +func TestHandleConnectionGrantRejectsDuplicateRequestID(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionGrant, + WorkflowID: "wf-1", + RequestID: "req-dup", + Username: "alice", + DurationSeconds: 60, + } + nonce := "fixed-nonce-dup" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{} + state := &stubStateStore{grants: []domain.GrantState{{ + RequestID: req.RequestID, + WorkflowID: req.WorkflowID, + Username: req.Username, + GrantedAtWallUTC: time.Now().UTC(), + GrantedAtMonoNS: 1, + DurationSeconds: req.DurationSeconds, + }}} + h := New(grantEngine, &stubVerifier{}, state, stubClock{mono: 123, wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.grantCalls != 0 { + t.Fatalf("expected duplicate request replay to skip grant, got %d grant calls", grantEngine.grantCalls) + } + + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusOK) +} + +func TestHandleConnectionGrantRejectsConflictingDuplicateRequestID(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionGrant, + WorkflowID: "wf-1", + RequestID: "req-dup-conflict", + Username: "alice", + DurationSeconds: 60, + } + nonce := "fixed-nonce-dup-conflict" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{} + state := &stubStateStore{grants: []domain.GrantState{{ + RequestID: req.RequestID, + WorkflowID: "wf-existing", + Username: "bob", + GrantedAtWallUTC: time.Now().UTC(), + GrantedAtMonoNS: 1, + DurationSeconds: 60, + }}} + h := New(grantEngine, &stubVerifier{}, state, stubClock{mono: 123, wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.grantCalls != 0 { + t.Fatalf("expected conflicting duplicate request ID to be rejected before grant, got %d grant calls", grantEngine.grantCalls) + } + + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusError) + assertResultErrorCode(t, conn.writeFrames[1], ErrorCodeRequestConflict) +} + +func TestHandleConnectionGrantRejectsActiveGrantForSameUser(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionGrant, + WorkflowID: "wf-1", + RequestID: "req-new", + Username: "alice", + DurationSeconds: 60, + } + nonce := "fixed-nonce-user-active" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{} + state := &stubStateStore{grants: []domain.GrantState{{ + RequestID: "req-existing", + WorkflowID: "wf-existing", + Username: req.Username, + GrantedAtWallUTC: time.Now().UTC(), + GrantedAtMonoNS: 1, + DurationSeconds: 60, + }}} + h := New(grantEngine, &stubVerifier{}, state, stubClock{mono: 123, wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.grantCalls != 0 { + t.Fatalf("expected active same-user grant to be rejected before grant, got %d grant calls", grantEngine.grantCalls) + } + + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusError) + assertResultErrorCode(t, conn.writeFrames[1], ErrorCodeActiveGrantExists) +} + +func TestHandleConnectionGrantPrefersMatchingRequestIDOverEarlierActiveUserGrant(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionGrant, + WorkflowID: "wf-1", + RequestID: "req-dup", + Username: "alice", + DurationSeconds: 60, + } + nonce := "fixed-nonce-dup-order" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{} + state := &stubStateStore{grants: []domain.GrantState{ + { + RequestID: "req-existing", + WorkflowID: "wf-existing", + Username: req.Username, + GrantedAtWallUTC: time.Now().UTC(), + GrantedAtMonoNS: 1, + DurationSeconds: 60, + }, + { + RequestID: req.RequestID, + WorkflowID: req.WorkflowID, + Username: req.Username, + GrantedAtWallUTC: time.Now().UTC(), + GrantedAtMonoNS: 1, + DurationSeconds: req.DurationSeconds, + }, + }} + h := New(grantEngine, &stubVerifier{}, state, stubClock{mono: 123, wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.grantCalls != 0 { + t.Fatalf("expected duplicate request replay to skip grant, got %d grant calls", grantEngine.grantCalls) + } + + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusOK) +} + +func TestHandleConnectionGrantAllowsNewGrantWhenExistingOneIsCompleted(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionGrant, + WorkflowID: "wf-1", + RequestID: "req-new", + Username: "alice", + DurationSeconds: 60, + } + nonce := "fixed-nonce-user-completed" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{grantResult: domain.GrantResult{}} + state := &stubStateStore{grants: []domain.GrantState{{ + RequestID: "req-old", + WorkflowID: "wf-existing", + Username: req.Username, + GrantedAtWallUTC: time.Now().UTC(), + GrantedAtMonoNS: 1, + DurationSeconds: 60, + CompletedAtWallUTC: time.Now().UTC(), + }}} + h := New(grantEngine, &stubVerifier{}, state, stubClock{mono: 123, wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.grantCalls != 1 { + t.Fatalf("expected completed grant not to block new grant, got %d grant calls", grantEngine.grantCalls) + } + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusOK) +} + +func TestHandleConnectionGrantAllowsNewGrantWhenExistingOneIsExpired(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionGrant, + WorkflowID: "wf-1", + RequestID: "req-new-expired", + Username: "alice", + DurationSeconds: 60, + } + nonce := "fixed-nonce-user-expired" + nowWall := time.Date(2026, 2, 28, 12, 0, 0, 0, time.UTC) + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{grantResult: domain.GrantResult{}} + state := &stubStateStore{grants: []domain.GrantState{{ + RequestID: "req-old-expired", + WorkflowID: "wf-existing", + Username: req.Username, + GrantedAtWallUTC: nowWall.Add(-2 * time.Minute), + GrantedAtMonoNS: int64(time.Minute), + DurationSeconds: 60, + }}} + h := New(grantEngine, &stubVerifier{}, state, stubClock{ + mono: int64(3 * time.Minute), + wall: nowWall, + }) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.grantCalls != 1 { + t.Fatalf("expected expired grant not to block new grant, got %d grant calls", grantEngine.grantCalls) + } + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusOK) +} + +func TestHandleConnectionGrantRejectsUnsafeUsername(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionGrant, + WorkflowID: "wf-1", + RequestID: "req-unsafe-user", + Username: "/delete", + DurationSeconds: 60, + } + nonce := "fixed-nonce-unsafe-grant" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{} + state := &stubStateStore{} + h := New(grantEngine, &stubVerifier{}, state, stubClock{mono: 123, wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.grantCalls != 0 { + t.Fatalf("expected unsafe username to be rejected before grant, got %d grant calls", grantEngine.grantCalls) + } + + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusError) + assertResultErrorCode(t, conn.writeFrames[1], ErrorCodeInvalidRequest) +} + +func TestHandleConnectionPayloadMismatchWritesError(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionGrant, + WorkflowID: "wf-1", + RequestID: "req-3", + Username: "alice", + DurationSeconds: 60, + } + nonce := "fixed-nonce-mismatch" + bad := req + bad.Username = "mallory" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, bad, nonce)), + }, + } + grantEngine := &stubGrantEngine{} + state := &stubStateStore{} + h := New(grantEngine, &stubVerifier{}, state, stubClock{}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.grantCalls != 0 { + t.Fatalf("expected no grant call, got %d", grantEngine.grantCalls) + } + + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusError) + assertResultErrorCode(t, conn.writeFrames[1], ErrorCodeUnauthorized) +} + +func TestHandleConnectionGrantMapsInvalidGrantRequestToInvalidRequest(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionGrant, + WorkflowID: "wf-1", + RequestID: "req-invalid-grant", + Username: "alice", + DurationSeconds: 60, + } + nonce := "fixed-nonce-invalid-grant" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{grantErr: grant.ErrInvalidGrantRequest} + state := &stubStateStore{} + h := New(grantEngine, &stubVerifier{}, state, stubClock{mono: 123, wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.grantCalls != 1 { + t.Fatalf("expected grant to be attempted once, got %d", grantEngine.grantCalls) + } + + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusError) + assertResultErrorCode(t, conn.writeFrames[1], ErrorCodeInvalidRequest) +} diff --git a/cmd/elevate/handler/handle_revoke.go b/cmd/elevate/handler/handle_revoke.go new file mode 100644 index 00000000..3b27e0e1 --- /dev/null +++ b/cmd/elevate/handler/handle_revoke.go @@ -0,0 +1,108 @@ +package handler + +import ( + "context" + "fmt" + + "github.com/thand-io/agent/cmd/elevate/domain" +) + +// handleRevoke is split into its own file so revoke flow can evolve independently. +func (h *Handler) handleRevoke(ctx context.Context, conn IPCConn, req domain.RequestFrame) error { + if err := h.authenticateRequest(ctx, conn, req); err != nil { + _ = h.writeRequestError(ctx, conn, req, err) + return nil + } + if err := validateRequestUsername(req.Username); err != nil { + return h.writeRequestError(ctx, conn, req, err) + } + + grants, err := h.stateStore.List(ctx) + if err != nil { + return h.writeRequestError(ctx, conn, req, wrapInternal("list grant state", err)) + } + + stored, found := findGrantState(grants, req.RequestID) + username := req.Username + if found { + if stored.WorkflowID != req.WorkflowID || stored.Username != req.Username { + return h.writeRequestError(ctx, conn, req, requestConflictErr(fmt.Errorf("request %q conflicts with existing state", req.RequestID))) + } + username = stored.Username + if domain.IsCompletedGrantState(stored) { + return h.writeRevokeSuccess(ctx, conn, req, username) + } + if stored.WasAlreadyPrivileged { + if err := h.completeGrantState(ctx, req, conn, stored); err != nil { + return err + } + return h.writeRevokeSuccess(ctx, conn, req, username) + } + } + + if err := h.grantEngine.Revoke(ctx, domain.RevokeRequest{ + RequestID: req.RequestID, + WorkflowID: req.WorkflowID, + Username: username, + }); err != nil { + return h.writeRequestError(ctx, conn, req, fmt.Errorf("revoke: %w", err)) + } + + if found { + if err := h.completeGrantState(ctx, req, conn, stored); err != nil { + return err + } + } + + return h.writeRevokeSuccess(ctx, conn, req, username) +} + +func (h *Handler) completeGrantState(ctx context.Context, req domain.RequestFrame, conn IPCConn, grant domain.GrantState) error { + if domain.IsCompletedGrantState(grant) { + return nil + } + + grant.CompletedAtWallUTC = h.clock.NowWallUTC() + if err := h.stateStore.Put(ctx, grant); err != nil { + if deleteErr := h.stateStore.Delete(ctx, grant.RequestID); deleteErr != nil { + return h.writeRequestError(ctx, conn, req, wrapInternal("persist completed grant state", fmt.Errorf("%w; delete fallback failed: %v", err, deleteErr))) + } + return nil + } + + return nil +} + +func (h *Handler) writeRequestError(ctx context.Context, conn IPCConn, req domain.RequestFrame, err error) error { + resErr := classifyResponseError(err) + if writeErr := h.writeResult(ctx, conn, req, resultStatusError, resErr.Code); writeErr != nil { + return writeErr + } + h.logFailure(req.Action, req.RequestID, resErr) + return nil +} + +func (h *Handler) writeRevokeSuccess(ctx context.Context, conn IPCConn, req domain.RequestFrame, username string) error { + if err := h.writeResult(ctx, conn, req, resultStatusOK, ""); err != nil { + return err + } + h.logger.Info("access revoked for "+username, + "component", "elevate_handler", + "action", string(req.Action), + "request_id", req.RequestID, + "workflow_id", req.WorkflowID, + "username", username, + "duration_seconds", req.DurationSeconds, + "status", "ok", + ) + return nil +} + +func findGrantState(grants []domain.GrantState, requestID string) (domain.GrantState, bool) { + for _, grant := range grants { + if grant.RequestID == requestID { + return grant, true + } + } + return domain.GrantState{}, false +} diff --git a/cmd/elevate/handler/handle_revoke_test.go b/cmd/elevate/handler/handle_revoke_test.go new file mode 100644 index 00000000..86c09397 --- /dev/null +++ b/cmd/elevate/handler/handle_revoke_test.go @@ -0,0 +1,276 @@ +package handler + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" +) + +func TestHandleConnectionRevokeSuccess(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionRevoke, + WorkflowID: "wf-1", + RequestID: "req-2", + Username: "alice", + } + nonce := "fixed-nonce-revoke" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{} + state := &stubStateStore{grants: []domain.GrantState{{ + RequestID: req.RequestID, + WorkflowID: req.WorkflowID, + Username: req.Username, + GrantedAtWallUTC: time.Now().UTC(), + GrantedAtMonoNS: 1, + DurationSeconds: 60, + }}} + h := New(grantEngine, &stubVerifier{}, state, stubClock{wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.revokeCalls != 1 { + t.Fatalf("expected 1 revoke call, got %d", grantEngine.revokeCalls) + } + if state.putCalls != 1 { + t.Fatalf("expected 1 state put, got %d", state.putCalls) + } + if len(state.grants) != 1 || state.grants[0].CompletedAtWallUTC.IsZero() { + t.Fatalf("expected revoke to mark stored grant completed, got %+v", state.grants) + } + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusOK) +} + +func TestHandleConnectionRevokeDeletesStateWhenCompletionPersistFails(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionRevoke, + WorkflowID: "wf-1", + RequestID: "req-2", + Username: "alice", + } + nonce := "fixed-nonce-revoke-delete-fallback" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{} + state := &stubStateStore{ + putErr: errors.New("put failed"), + grants: []domain.GrantState{{ + RequestID: req.RequestID, + WorkflowID: req.WorkflowID, + Username: req.Username, + GrantedAtWallUTC: time.Now().UTC(), + GrantedAtMonoNS: 1, + DurationSeconds: 60, + }}, + } + h := New(grantEngine, &stubVerifier{}, state, stubClock{wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.revokeCalls != 1 { + t.Fatalf("expected 1 revoke call, got %d", grantEngine.revokeCalls) + } + if state.deleteCalls != 1 { + t.Fatalf("expected delete fallback after failed completion put, got %d delete calls", state.deleteCalls) + } + if len(state.grants) != 0 { + t.Fatalf("expected state to be deleted after fallback, got %+v", state.grants) + } + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusOK) +} + +func TestHandleConnectionRevokeWithoutStateStillRevokes(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionRevoke, + WorkflowID: "wf-1", + RequestID: "req-missing", + Username: "alice", + } + nonce := "fixed-nonce-revoke-missing" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{} + state := &stubStateStore{} + h := New(grantEngine, &stubVerifier{}, state, stubClock{wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.revokeCalls != 1 { + t.Fatalf("expected revoke without state to still revoke, got %d", grantEngine.revokeCalls) + } + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusOK) +} + +func TestHandleConnectionRevokeSkipsPrivilegeRemovalForBaselineGrant(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionRevoke, + WorkflowID: "wf-1", + RequestID: "req-baseline", + Username: "alice", + } + nonce := "fixed-nonce-revoke-baseline" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{} + state := &stubStateStore{grants: []domain.GrantState{{ + RequestID: req.RequestID, + WorkflowID: req.WorkflowID, + Username: req.Username, + GrantedAtWallUTC: time.Now().UTC(), + GrantedAtMonoNS: 1, + DurationSeconds: 60, + WasAlreadyPrivileged: true, + }}} + h := New(grantEngine, &stubVerifier{}, state, stubClock{wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.revokeCalls != 0 { + t.Fatalf("expected baseline revoke to skip privilege removal, got %d", grantEngine.revokeCalls) + } + if len(state.grants) != 1 || state.grants[0].CompletedAtWallUTC.IsZero() { + t.Fatalf("expected baseline grant to be marked completed, got %+v", state.grants) + } + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusOK) +} + +func TestHandleConnectionRevokeRejectsStoredIdentityMismatch(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionRevoke, + WorkflowID: "wf-request", + RequestID: "req-mismatch", + Username: "alice", + } + nonce := "fixed-nonce-revoke-mismatch" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{} + state := &stubStateStore{grants: []domain.GrantState{{ + RequestID: req.RequestID, + WorkflowID: "wf-stored", + Username: "bob", + GrantedAtWallUTC: time.Now().UTC(), + GrantedAtMonoNS: 1, + DurationSeconds: 60, + }}} + h := New(grantEngine, &stubVerifier{}, state, stubClock{wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.revokeCalls != 0 { + t.Fatalf("expected stored-identity mismatch to be rejected before revoke, got %d revoke calls", grantEngine.revokeCalls) + } + if state.putCalls != 0 { + t.Fatalf("expected mismatch not to update state, got %d puts", state.putCalls) + } + + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusError) + assertResultErrorCode(t, conn.writeFrames[1], ErrorCodeRequestConflict) +} + +func TestHandleConnectionRevokeRejectsUnsafeUsername(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionRevoke, + WorkflowID: "wf-1", + RequestID: "req-unsafe-revoke", + Username: " alice ", + } + nonce := "fixed-nonce-unsafe-revoke" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{} + state := &stubStateStore{} + h := New(grantEngine, &stubVerifier{}, state, stubClock{wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.revokeCalls != 0 { + t.Fatalf("expected unsafe username to be rejected before revoke, got %d revoke calls", grantEngine.revokeCalls) + } + + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusError) + assertResultErrorCode(t, conn.writeFrames[1], ErrorCodeInvalidRequest) +} + +func TestHandleConnectionRevokeRejectsOverlongUsername(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.ActionRevoke, + WorkflowID: "wf-1", + RequestID: "req-long-user", + Username: "abcdefghijklmnopqrstuvwxyzabcdefg", + } + nonce := "fixed-nonce-long-revoke" + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + mustJSON(t, signedResponseFor(t, req, nonce)), + }, + } + grantEngine := &stubGrantEngine{} + state := &stubStateStore{} + h := New(grantEngine, &stubVerifier{}, state, stubClock{wall: time.Now().UTC()}) + h.generateNonce = func() (string, error) { return nonce, nil } + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + if grantEngine.revokeCalls != 0 { + t.Fatalf("expected overlong username to be rejected before revoke, got %d revoke calls", grantEngine.revokeCalls) + } + + assertChallengeAndResult(t, conn.writeFrames, nonce, req.RequestID, resultStatusError) + assertResultErrorCode(t, conn.writeFrames[1], ErrorCodeInvalidRequest) +} diff --git a/cmd/elevate/handler/handler.go b/cmd/elevate/handler/handler.go new file mode 100644 index 00000000..1a27d22f --- /dev/null +++ b/cmd/elevate/handler/handler.go @@ -0,0 +1,120 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" + "github.com/thand-io/agent/cmd/elevate/verify" +) + +var ErrUnsupportedAction = errors.New("unsupported action") + +const defaultRequestTimeout = 30 * time.Second + +// Handler routes incoming request frames to action-specific handlers. +type Handler struct { + grantEngine GrantEngine + verifier SignatureVerifier + stateStore StateStore + clock Clock + logger *slog.Logger + + requestTimeout time.Duration + generateNonce func() (string, error) +} + +// Option configures handler behavior. +type Option func(*Handler) + +// WithLogger sets the logger used by the handler. +func WithLogger(logger *slog.Logger) Option { + return func(h *Handler) { + if logger != nil { + h.logger = logger + } + } +} + +// WithRequestTimeout sets the per-connection request timeout. +func WithRequestTimeout(timeout time.Duration) Option { + return func(h *Handler) { + if timeout > 0 { + h.requestTimeout = timeout + } + } +} + +// New constructs a handler for elevate request processing. +func New(grantEngine GrantEngine, verifier SignatureVerifier, stateStore StateStore, clock Clock, opts ...Option) *Handler { + h := &Handler{ + grantEngine: grantEngine, + verifier: verifier, + stateStore: stateStore, + clock: clock, + logger: slog.Default(), + requestTimeout: defaultRequestTimeout, + generateNonce: verify.GenerateNonce, + } + for _, opt := range opts { + opt(h) + } + return h +} + +// HandleConnection is the per-connection router. +func (h *Handler) HandleConnection(ctx context.Context, conn IPCConn) error { + reqCtx, cancel := context.WithTimeout(ctx, h.requestTimeout) + defer cancel() + + frameBytes, err := conn.ReadFrame(reqCtx) + if err != nil { + return fmt.Errorf("read frame: %w", err) + } + + var req domain.RequestFrame + if err := json.Unmarshal(frameBytes, &req); err != nil { + return fmt.Errorf("decode request frame: %w", err) + } + if req.Type != domain.FrameTypeRequest { + return fmt.Errorf("invalid request frame type: %s", req.Type) + } + + switch req.Action { + case domain.ActionGrant: + return h.handleGrant(reqCtx, conn, req) + case domain.ActionRevoke: + return h.handleRevoke(reqCtx, conn, req) + default: + return h.writeRequestError(reqCtx, conn, req, invalidRequestErr(fmt.Errorf("%w: %s", ErrUnsupportedAction, req.Action))) + } +} + +func (h *Handler) logFailure(action domain.Action, requestID string, err *responseError) { + level := slog.LevelWarn + if err.Code == ErrorCodeInternal { + level = slog.LevelError + } + + h.logger.Log(context.Background(), level, "elevate request failed", + "component", "elevate_handler", + "action", string(action), + "request_id", requestID, + "status", "error", + "code", string(err.Code), + ) + + if err.Cause != nil && h.logger.Enabled(context.Background(), slog.LevelDebug) { + h.logger.Debug("elevate request failure detail", + "component", "elevate_handler", + "action", string(action), + "request_id", requestID, + "code", string(err.Code), + "cause", err.Cause.Error(), + ) + } +} diff --git a/cmd/elevate/handler/handler_test.go b/cmd/elevate/handler/handler_test.go new file mode 100644 index 00000000..e19e6468 --- /dev/null +++ b/cmd/elevate/handler/handler_test.go @@ -0,0 +1,34 @@ +package handler + +import ( + "context" + "testing" + + "github.com/thand-io/agent/cmd/elevate/domain" +) + +func TestHandleConnectionUnsupportedActionWritesProtocolError(t *testing.T) { + req := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.Action("unknown"), + WorkflowID: "wf-1", + RequestID: "req-unsupported", + Username: "alice", + } + + conn := &stubConn{ + readFrames: [][]byte{ + mustJSON(t, req), + }, + } + h := New(&stubGrantEngine{}, &stubVerifier{}, &stubStateStore{}, stubClock{}) + + if err := h.HandleConnection(context.Background(), conn); err != nil { + t.Fatalf("HandleConnection failed: %v", err) + } + + if len(conn.writeFrames) != 1 { + t.Fatalf("expected 1 result frame, got %d writes", len(conn.writeFrames)) + } + assertResultErrorCode(t, conn.writeFrames[0], ErrorCodeInvalidRequest) +} diff --git a/cmd/elevate/handler/interfaces.go b/cmd/elevate/handler/interfaces.go new file mode 100644 index 00000000..8fcb6172 --- /dev/null +++ b/cmd/elevate/handler/interfaces.go @@ -0,0 +1,46 @@ +package handler + +import ( + "context" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" +) + +// IPCServer abstracts how the helper accepts local IPC connections. +type IPCServer interface { + Start(ctx context.Context) error + Accept(ctx context.Context) (IPCConn, error) + Close() error +} + +// IPCConn abstracts framed request/response exchange over IPC. +type IPCConn interface { + ReadFrame(ctx context.Context) ([]byte, error) + WriteFrame(ctx context.Context, data []byte) error + Close() error +} + +// GrantEngine abstracts OS-specific local admin grant/revoke behavior. +type GrantEngine interface { + Grant(ctx context.Context, req domain.GrantRequest) (domain.GrantResult, error) + Revoke(ctx context.Context, req domain.RevokeRequest) error +} + +// SignatureVerifier validates server signatures for signed payloads. +type SignatureVerifier interface { + Verify(keyID string, payload []byte, signature []byte) error +} + +// StateStore manages active grant persistence for recovery/cleanup. +type StateStore interface { + Put(ctx context.Context, grant domain.GrantState) error + Delete(ctx context.Context, requestID string) error + List(ctx context.Context) ([]domain.GrantState, error) +} + +// Clock abstracts time for deterministic tests and monotonic expiry logic. +type Clock interface { + NowMonoNS() int64 + NowWallUTC() time.Time +} diff --git a/cmd/elevate/handler/test_helpers_test.go b/cmd/elevate/handler/test_helpers_test.go new file mode 100644 index 00000000..32ecdb84 --- /dev/null +++ b/cmd/elevate/handler/test_helpers_test.go @@ -0,0 +1,193 @@ +package handler + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" + "github.com/thand-io/agent/cmd/elevate/verify" +) + +type stubConn struct { + readFrames [][]byte + readIndex int + writeFrames [][]byte +} + +func (c *stubConn) ReadFrame(ctx context.Context) ([]byte, error) { + _ = ctx + if c.readIndex >= len(c.readFrames) { + return nil, errors.New("no more read frames") + } + out := c.readFrames[c.readIndex] + c.readIndex++ + return out, nil +} + +func (c *stubConn) WriteFrame(ctx context.Context, data []byte) error { + _ = ctx + c.writeFrames = append(c.writeFrames, data) + return nil +} + +func (c *stubConn) Close() error { return nil } + +type stubGrantEngine struct { + grantCalls int + revokeCalls int + grantResult domain.GrantResult + grantErr error + revokeErr error +} + +func (s *stubGrantEngine) Grant(ctx context.Context, req domain.GrantRequest) (domain.GrantResult, error) { + _ = ctx + _ = req + s.grantCalls++ + return s.grantResult, s.grantErr +} + +func (s *stubGrantEngine) Revoke(ctx context.Context, req domain.RevokeRequest) error { + _ = ctx + _ = req + s.revokeCalls++ + return s.revokeErr +} + +type stubVerifier struct { + err error +} + +func (s *stubVerifier) Verify(keyID string, payload []byte, signature []byte) error { + _ = keyID + _ = payload + _ = signature + return s.err +} + +type stubStateStore struct { + putCalls int + deleteCalls int + putErr error + deleteErr error + grants []domain.GrantState +} + +func (s *stubStateStore) Put(ctx context.Context, grant domain.GrantState) error { + _ = ctx + s.putCalls++ + for i := range s.grants { + if s.grants[i].RequestID == grant.RequestID { + s.grants[i] = grant + return s.putErr + } + } + s.grants = append(s.grants, grant) + return s.putErr +} + +func (s *stubStateStore) Delete(ctx context.Context, requestID string) error { + _ = ctx + s.deleteCalls++ + if s.deleteErr != nil { + return s.deleteErr + } + filtered := s.grants[:0] + for _, grant := range s.grants { + if grant.RequestID != requestID { + filtered = append(filtered, grant) + } + } + s.grants = filtered + return nil +} + +func (s *stubStateStore) List(ctx context.Context) ([]domain.GrantState, error) { + _ = ctx + out := make([]domain.GrantState, len(s.grants)) + copy(out, s.grants) + return out, nil +} + +type stubClock struct { + mono int64 + wall time.Time +} + +func (c stubClock) NowMonoNS() int64 { return c.mono } +func (c stubClock) NowWallUTC() time.Time { return c.wall } + +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return b +} + +func signedResponseFor(t *testing.T, req domain.RequestFrame, nonce string) domain.SignedResponseFrame { + t.Helper() + payload := verify.SignedPayload{ + Nonce: nonce, + Action: string(req.Action), + WorkflowID: req.WorkflowID, + RequestID: req.RequestID, + Username: req.Username, + DurationSeconds: req.DurationSeconds, + } + canonical, err := verify.CanonicalPayload(payload) + if err != nil { + t.Fatalf("canonical payload: %v", err) + } + return domain.SignedResponseFrame{ + Type: domain.FrameTypeSignedResponse, + KeyID: "test-key", + Signature: base64.StdEncoding.EncodeToString([]byte{0}), + SignedPayload: base64.StdEncoding.EncodeToString(canonical), + } +} + +func assertChallengeAndResult(t *testing.T, writes [][]byte, expectedNonce, expectedRequestID, expectedStatus string) { + t.Helper() + if len(writes) != 2 { + t.Fatalf("expected 2 writes (challenge+result), got %d", len(writes)) + } + + var challenge domain.ChallengeFrame + if err := json.Unmarshal(writes[0], &challenge); err != nil { + t.Fatalf("decode challenge: %v", err) + } + if challenge.Type != domain.FrameTypeChallenge || challenge.Nonce != expectedNonce { + t.Fatalf("unexpected challenge: %+v", challenge) + } + + var result domain.ResultFrame + if err := json.Unmarshal(writes[1], &result); err != nil { + t.Fatalf("decode result: %v", err) + } + if result.Type != domain.FrameTypeResult { + t.Fatalf("unexpected result type: %s", result.Type) + } + if result.RequestID != expectedRequestID { + t.Fatalf("unexpected result request_id: got %s want %s", result.RequestID, expectedRequestID) + } + if result.Status != expectedStatus { + t.Fatalf("unexpected result status: got %s want %s", result.Status, expectedStatus) + } +} + +func assertResultErrorCode(t *testing.T, frame []byte, expected ErrorCode) { + t.Helper() + var result domain.ResultFrame + if err := json.Unmarshal(frame, &result); err != nil { + t.Fatalf("decode result: %v", err) + } + if result.Error != string(expected) { + t.Fatalf("unexpected error code: got %q want %q", result.Error, expected) + } +} diff --git a/cmd/elevate/handler/validation.go b/cmd/elevate/handler/validation.go new file mode 100644 index 00000000..7ecafaa9 --- /dev/null +++ b/cmd/elevate/handler/validation.go @@ -0,0 +1,20 @@ +package handler + +import ( + "fmt" + + "github.com/thand-io/agent/cmd/elevate/identity" +) + +func validateRequestUsername(username string) *responseError { + switch { + case username == "": + return invalidRequestErr(fmt.Errorf("username is required")) + case len(username) > identity.MaxAccountNameLength: + return invalidRequestErr(fmt.Errorf("username exceeds maximum length")) + case !identity.ValidAccountName(username): + return invalidRequestErr(fmt.Errorf("username contains unsupported characters")) + default: + return nil + } +} diff --git a/cmd/elevate/handler/validation_test.go b/cmd/elevate/handler/validation_test.go new file mode 100644 index 00000000..5a32bcd0 --- /dev/null +++ b/cmd/elevate/handler/validation_test.go @@ -0,0 +1,35 @@ +package handler + +import "testing" + +func TestValidateRequestUsername(t *testing.T) { + tests := []struct { + name string + username string + wantErr bool + }{ + {name: "empty", username: "", wantErr: true}, + {name: "starts with letter", username: "alice", wantErr: false}, + {name: "starts with underscore", username: "_svc", wantErr: false}, + {name: "allows dot and hyphen", username: "alice.dev-1", wantErr: false}, + {name: "allows underscore and trailing dollar", username: "svc_app$", wantErr: false}, + {name: "rejects leading hyphen", username: "-alice", wantErr: true}, + {name: "rejects leading dot", username: ".alice", wantErr: true}, + {name: "rejects slash", username: "/delete", wantErr: true}, + {name: "rejects whitespace", username: "alice bob", wantErr: true}, + {name: "rejects trailing whitespace", username: "alice ", wantErr: true}, + {name: "rejects overlong", username: "abcdefghijklmnopqrstuvwxyzabcdefg", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRequestUsername(tt.username) + if tt.wantErr && err == nil { + t.Fatalf("expected validation error for %q", tt.username) + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected validation error for %q: %v", tt.username, err) + } + }) + } +} diff --git a/cmd/elevate/identity/validation.go b/cmd/elevate/identity/validation.go new file mode 100644 index 00000000..c66b1261 --- /dev/null +++ b/cmd/elevate/identity/validation.go @@ -0,0 +1,33 @@ +package identity + +import ( + "regexp" +) + +const ( + // MaxAccountNameLength caps local usernames and group names accepted by the helper. + MaxAccountNameLength = 32 + // MaxWindowsAdminGroupLength caps the configured Windows admin group name. + MaxWindowsAdminGroupLength = 64 +) + +var ( + accountNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9._-]*[$]?$`) + windowsAdminGroupPattern = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9 ._-]*$`) +) + +// ValidAccountName reports whether v is a conservative helper-safe local account or group name. +func ValidAccountName(v string) bool { + if v == "" || len(v) > MaxAccountNameLength { + return false + } + return accountNamePattern.MatchString(v) +} + +// ValidWindowsAdminGroup reports whether v is a helper-safe Windows local admin group name. +func ValidWindowsAdminGroup(v string) bool { + if v == "" || len(v) > MaxWindowsAdminGroupLength { + return false + } + return windowsAdminGroupPattern.MatchString(v) +} diff --git a/cmd/elevate/identity/validation_test.go b/cmd/elevate/identity/validation_test.go new file mode 100644 index 00000000..8a5fa86c --- /dev/null +++ b/cmd/elevate/identity/validation_test.go @@ -0,0 +1,53 @@ +package identity + +import ( + "strings" + "testing" +) + +func TestValidAccountName(t *testing.T) { + tests := []struct { + name string + value string + want bool + }{ + {name: "simple", value: "alice", want: true}, + {name: "underscore", value: "svc_app", want: true}, + {name: "trailing dollar", value: "machine$", want: true}, + {name: "dot and dash", value: "svc.app-1", want: true}, + {name: "empty", value: "", want: false}, + {name: "space", value: "alice smith", want: false}, + {name: "slash", value: "alice/bob", want: false}, + {name: "backslash", value: `DOMAIN\alice`, want: false}, + {name: "upn", value: "alice@example.com", want: false}, + {name: "too long", value: strings.Repeat("a", MaxAccountNameLength+1), want: false}, + } + + for _, tc := range tests { + if got := ValidAccountName(tc.value); got != tc.want { + t.Fatalf("%s: got %t want %t for %q", tc.name, got, tc.want, tc.value) + } + } +} + +func TestValidWindowsAdminGroup(t *testing.T) { + tests := []struct { + name string + value string + want bool + }{ + {name: "default", value: "Administrators", want: true}, + {name: "with space", value: "Remote Desktop Users", want: true}, + {name: "dash", value: "Ops-Admins", want: true}, + {name: "empty", value: "", want: false}, + {name: "semicolon", value: "Administrators; Remove-Item *", want: false}, + {name: "leading digit", value: "123Admins", want: false}, + {name: "too long", value: strings.Repeat("A", MaxWindowsAdminGroupLength+1), want: false}, + } + + for _, tc := range tests { + if got := ValidWindowsAdminGroup(tc.value); got != tc.want { + t.Fatalf("%s: got %t want %t for %q", tc.name, got, tc.want, tc.value) + } + } +} diff --git a/cmd/elevate/ipc/ipc.go b/cmd/elevate/ipc/ipc.go new file mode 100644 index 00000000..ef373ffa --- /dev/null +++ b/cmd/elevate/ipc/ipc.go @@ -0,0 +1,449 @@ +package ipc + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "net" + "os" + "os/exec" + osuser "os/user" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/thand-io/agent/cmd/elevate/handler" +) + +const ( + defaultReadPollInterval = 250 * time.Millisecond + defaultWritePollInterval = 250 * time.Millisecond + defaultMaxFrameBytes = 16 * 1024 +) + +var ( + ErrSocketPathRequired = errors.New("unix socket path is required") + ErrServerNotStarted = errors.New("unix server not started") + ErrFrameTooLarge = errors.New("ipc frame too large") +) + +type UnixServer struct { + path string + dirPerm os.FileMode + socketPerm os.FileMode + socketUser string + socketGrp string + maxFrame int + + listener unixListener + + mkdirAll func(path string, perm os.FileMode) error + lstat func(name string) (os.FileInfo, error) + remove func(name string) error + listenUnix func(network string, laddr *net.UnixAddr) (unixListener, error) + chmod func(name string, mode os.FileMode) error + chown func(name string, uid, gid int) error + lookupUser func(username string) (*osuser.User, error) + lookupGrp func(groupname string) (*osuser.Group, error) + runCommand func(name string, args ...string) error + now func() time.Time + newConn func(*net.UnixConn) handler.IPCConn +} + +type acceptResult struct { + conn *net.UnixConn + err error +} + +type unixListener interface { + AcceptUnix() (*net.UnixConn, error) + SetDeadline(t time.Time) error + Close() error +} + +type Option func(*UnixServer) + +// WithMkdirAll overrides directory creation for testability. +func WithMkdirAll(fn func(path string, perm os.FileMode) error) Option { + return func(s *UnixServer) { s.mkdirAll = fn } +} + +// WithLstat overrides filesystem stat for testability. +func WithLstat(fn func(name string) (os.FileInfo, error)) Option { + return func(s *UnixServer) { s.lstat = fn } +} + +// WithRemove overrides filesystem remove for testability. +func WithRemove(fn func(name string) error) Option { + return func(s *UnixServer) { s.remove = fn } +} + +// WithListenUnix overrides Unix listener creation for testability. +func WithListenUnix(fn func(network string, laddr *net.UnixAddr) (unixListener, error)) Option { + return func(s *UnixServer) { s.listenUnix = fn } +} + +// WithChmod overrides chmod for testability. +func WithChmod(fn func(name string, mode os.FileMode) error) Option { + return func(s *UnixServer) { s.chmod = fn } +} + +// WithChown overrides chown for testability. +func WithChown(fn func(name string, uid, gid int) error) Option { + return func(s *UnixServer) { s.chown = fn } +} + +// WithSocketUser sets socket owner username. +func WithSocketUser(username string) Option { + return func(s *UnixServer) { s.socketUser = strings.TrimSpace(username) } +} + +// WithSocketGroup sets socket group name. +func WithSocketGroup(group string) Option { + return func(s *UnixServer) { s.socketGrp = strings.TrimSpace(group) } +} + +// WithLookupUser overrides username lookup for testability. +func WithLookupUser(fn func(username string) (*osuser.User, error)) Option { + return func(s *UnixServer) { s.lookupUser = fn } +} + +// WithLookupGroup overrides group lookup for testability. +func WithLookupGroup(fn func(groupname string) (*osuser.Group, error)) Option { + return func(s *UnixServer) { s.lookupGrp = fn } +} + +// WithMaxFrameBytes overrides the max permitted IPC frame size. +func WithMaxFrameBytes(max int) Option { + return func(s *UnixServer) { s.maxFrame = max } +} + +// WithNow overrides time source for testability. +func WithNow(fn func() time.Time) Option { + return func(s *UnixServer) { s.now = fn } +} + +// WithRunCommand overrides external command execution for testability. +func WithRunCommand(fn func(name string, args ...string) error) Option { + return func(s *UnixServer) { s.runCommand = fn } +} + +// WithNewConn overrides IPC connection wrapper for testability. +func WithNewConn(fn func(*net.UnixConn) handler.IPCConn) Option { + return func(s *UnixServer) { s.newConn = fn } +} + +func NewUnixServer(path string, opts ...Option) (handler.IPCServer, error) { + if path == "" { + return nil, ErrSocketPathRequired + } + + srv := &UnixServer{ + path: path, + dirPerm: 0o750, + socketPerm: 0o660, + maxFrame: defaultMaxFrameBytes, + mkdirAll: os.MkdirAll, + lstat: os.Lstat, + remove: os.Remove, + listenUnix: func(network string, laddr *net.UnixAddr) (unixListener, error) { return net.ListenUnix(network, laddr) }, + chmod: os.Chmod, + chown: os.Chown, + lookupUser: osuser.Lookup, + lookupGrp: osuser.LookupGroup, + runCommand: func(name string, args ...string) error { + cmd := exec.Command(name, args...) + return cmd.Run() + }, + now: time.Now, + } + srv.newConn = func(conn *net.UnixConn) handler.IPCConn { + return newUnixConn(conn, srv.maxFrame) + } + + for _, opt := range opts { + opt(srv) + } + if srv.maxFrame <= 0 { + return nil, fmt.Errorf("max frame bytes must be > 0") + } + + return srv, nil +} + +// Start initializes the Unix socket listener at the configured path. +func (s *UnixServer) Start(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + + if s.listener != nil { + return nil + } + + dir := filepath.Dir(s.path) + if err := s.mkdirAll(dir, s.dirPerm); err != nil { + return fmt.Errorf("create unix socket directory: %w", err) + } + if err := s.configureSocketDirAccess(dir); err != nil { + return err + } + + if err := s.removeStaleSocket(s.path); err != nil { + return err + } + + var err error + s.listener, err = s.listenUnix("unix", &net.UnixAddr{Name: s.path, Net: "unix"}) + if err != nil { + return fmt.Errorf("listen on unix socket: %w", err) + } + + if err := s.configureSocketFileAccess(s.path); err != nil { + _ = s.listener.Close() + s.listener = nil + return err + } + + return nil +} + +func (s *UnixServer) resolveSocketOwnership() (uid int, gid int, has bool, err error) { + if s.socketUser == "" && s.socketGrp == "" { + return -1, -1, false, nil + } + + uid = -1 + gid = -1 + if s.socketGrp != "" { + group, lookupErr := s.lookupGrp(s.socketGrp) + if lookupErr != nil { + return -1, -1, false, fmt.Errorf("lookup socket group %q: %w", s.socketGrp, lookupErr) + } + parsedGID, parseErr := strconv.Atoi(group.Gid) + if parseErr != nil { + return -1, -1, false, fmt.Errorf("parse socket group id for %q: %w", s.socketGrp, parseErr) + } + gid = parsedGID + } + + if s.socketUser != "" { + user, lookupErr := s.lookupUser(s.socketUser) + if lookupErr != nil { + return -1, -1, false, fmt.Errorf("lookup socket user %q: %w", s.socketUser, lookupErr) + } + parsedUID, parseErr := strconv.Atoi(user.Uid) + if parseErr != nil { + return -1, -1, false, fmt.Errorf("parse socket user id for %q: %w", s.socketUser, parseErr) + } + uid = parsedUID + if gid == -1 { + parsedGID, parseGIDErr := strconv.Atoi(user.Gid) + if parseGIDErr != nil { + return -1, -1, false, fmt.Errorf("parse primary group id for socket user %q: %w", s.socketUser, parseGIDErr) + } + gid = parsedGID + } + } + + return uid, gid, true, nil +} + +// Accept waits for a single client connection and returns it as an IPCConn. +func (s *UnixServer) Accept(ctx context.Context) (handler.IPCConn, error) { + if s.listener == nil { + return nil, ErrServerNotStarted + } + + resultCh := acceptAsync(s.listener) + + select { + case <-ctx.Done(): + return nil, s.handleAcceptCancel(ctx, s.listener, resultCh) + case res := <-resultCh: + return s.handleAcceptResult(ctx, res) + } +} + +// Close shuts down the listener and removes the socket file path. +func (s *UnixServer) Close() error { + if s.listener != nil { + if err := s.listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + return err + } + s.listener = nil + } + + if err := s.remove(s.path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + return nil +} + +// removeStaleSocket removes an existing socket file so a new listener can bind. +func (s *UnixServer) removeStaleSocket(path string) error { + info, err := s.lstat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("stat unix socket path: %w", err) + } + + if info.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("refusing to remove non-socket path: %s", path) + } + + if err := s.remove(path); err != nil { + return fmt.Errorf("remove stale unix socket: %w", err) + } + + return nil +} + +type unixConn struct { + conn net.Conn + reader *bufio.Reader + max int +} + +func newUnixConn(conn *net.UnixConn, maxFrame int) handler.IPCConn { + return &unixConn{ + conn: conn, + reader: bufio.NewReaderSize(conn, maxFrame+1), + max: maxFrame, + } +} + +// ReadFrame reads one newline-delimited frame from the connection. +func (c *unixConn) ReadFrame(ctx context.Context) ([]byte, error) { + var frame []byte + for { + if err := ctx.Err(); err != nil { + return nil, err + } + + // Frame reads are newline-delimited; deadline polling keeps reads cancelable. + if err := c.conn.SetReadDeadline(time.Now().Add(defaultReadPollInterval)); err != nil { + return nil, fmt.Errorf("set read deadline: %w", err) + } + + line, err := c.reader.ReadSlice('\n') + if len(line) > 0 { + frame = append(frame, line...) + if len(frame) > c.max { + return nil, ErrFrameTooLarge + } + } + if err == nil { + return append([]byte(nil), normalizeReadFrame(frame)...), nil + } + + if errors.Is(err, bufio.ErrBufferFull) { + return nil, ErrFrameTooLarge + } + if isTimeoutErr(err) { + continue + } + + return nil, fmt.Errorf("read frame: %w", err) + } +} + +// WriteFrame writes one newline-delimited frame to the connection. +func (c *unixConn) WriteFrame(ctx context.Context, data []byte) error { + frame := normalizeWriteFrame(data) + written := 0 + for written < len(frame) { + if err := ctx.Err(); err != nil { + return err + } + + // Use bounded writes so we can honor context cancellation/deadlines. + deadline := time.Now().Add(defaultWritePollInterval) + if d, ok := ctx.Deadline(); ok && d.Before(deadline) { + deadline = d + } + if err := c.conn.SetWriteDeadline(deadline); err != nil { + return fmt.Errorf("set write deadline: %w", err) + } + + n, err := c.conn.Write(frame[written:]) + written += n + if err == nil { + continue + } + if isTimeoutErr(err) { + continue + } + return fmt.Errorf("write frame: %w", err) + } + + return nil +} + +// Close closes the underlying Unix domain socket connection. +func (c *unixConn) Close() error { + return c.conn.Close() +} + +func isTimeoutErr(err error) bool { + var netErr net.Error + return errors.As(err, &netErr) && netErr.Timeout() +} + +// acceptAsync performs a blocking AcceptUnix call in a goroutine. +func acceptAsync(l unixListener) <-chan acceptResult { + resultCh := make(chan acceptResult, 1) + go func() { + conn, err := l.AcceptUnix() + resultCh <- acceptResult{conn: conn, err: err} + }() + return resultCh +} + +// handleAcceptCancel unblocks and drains a pending accept on context cancellation. +func (s *UnixServer) handleAcceptCancel(ctx context.Context, l unixListener, resultCh <-chan acceptResult) error { + // AcceptUnix is blocking. Force it to return so the goroutine can exit. + _ = l.SetDeadline(s.now()) + res := <-resultCh + if res.conn != nil { + _ = res.conn.Close() + } + return ctx.Err() +} + +// handleAcceptResult converts a raw accept result into IPCConn or wrapped error. +func (s *UnixServer) handleAcceptResult(ctx context.Context, res acceptResult) (handler.IPCConn, error) { + if res.err == nil { + return s.newConn(res.conn), nil + } + + if errors.Is(res.err, net.ErrClosed) { + if ctx.Err() != nil { + return nil, ctx.Err() + } + return nil, res.err + } + + return nil, fmt.Errorf("accept unix connection: %w", res.err) +} + +// normalizeReadFrame trims CR/LF framing bytes from an incoming frame. +func normalizeReadFrame(line []byte) []byte { + return bytes.TrimRight(line, "\r\n") +} + +// normalizeWriteFrame ensures a frame ends with newline without mutating caller input. +func normalizeWriteFrame(data []byte) []byte { + // Copy caller input to avoid mutating shared buffers when appending newline. + frame := append([]byte(nil), data...) + if len(frame) == 0 || frame[len(frame)-1] != '\n' { + frame = append(frame, '\n') + } + return frame +} diff --git a/cmd/elevate/ipc/ipc_access_unix.go b/cmd/elevate/ipc/ipc_access_unix.go new file mode 100644 index 00000000..34a63619 --- /dev/null +++ b/cmd/elevate/ipc/ipc_access_unix.go @@ -0,0 +1,35 @@ +//go:build linux || darwin + +package ipc + +import "fmt" + +func (s *UnixServer) configureSocketDirAccess(dir string) error { + uid, gid, has, err := s.resolveSocketOwnership() + if err != nil { + return err + } + if !has { + return nil + } + if err := s.chown(dir, uid, gid); err != nil { + return fmt.Errorf("set unix socket directory ownership: %w", err) + } + return nil +} + +func (s *UnixServer) configureSocketFileAccess(path string) error { + if err := s.chmod(path, s.socketPerm); err != nil { + return fmt.Errorf("set unix socket permissions: %w", err) + } + uid, gid, has, err := s.resolveSocketOwnership() + if err != nil { + return err + } + if has { + if err := s.chown(path, uid, gid); err != nil { + return fmt.Errorf("set unix socket ownership: %w", err) + } + } + return nil +} diff --git a/cmd/elevate/ipc/ipc_access_windows.go b/cmd/elevate/ipc/ipc_access_windows.go new file mode 100644 index 00000000..f786306f --- /dev/null +++ b/cmd/elevate/ipc/ipc_access_windows.go @@ -0,0 +1,33 @@ +//go:build windows + +package ipc + +import "fmt" + +func (s *UnixServer) configureSocketDirAccess(dir string) error { + return s.applyWindowsACL(dir) +} + +func (s *UnixServer) configureSocketFileAccess(path string) error { + return s.applyWindowsACL(path) +} + +func (s *UnixServer) applyWindowsACL(path string) error { + if s.socketUser == "" { + return nil + } + + args := []string{ + path, + "/inheritance:r", + "/grant:r", "SYSTEM:(F)", + } + if s.socketUser != "" { + args = append(args, "/grant", fmt.Sprintf("%s:(M)", s.socketUser)) + } + + if err := s.runCommand("icacls", args...); err != nil { + return fmt.Errorf("set windows socket ACL on %q: %w", path, err) + } + return nil +} diff --git a/cmd/elevate/ipc/ipc_test.go b/cmd/elevate/ipc/ipc_test.go new file mode 100644 index 00000000..04234cc7 --- /dev/null +++ b/cmd/elevate/ipc/ipc_test.go @@ -0,0 +1,580 @@ +//go:build linux || darwin + +package ipc + +import ( + "bufio" + "bytes" + "context" + "errors" + "io" + "net" + "os" + osuser "os/user" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/thand-io/agent/cmd/elevate/handler" +) + +type fakeListener struct { + acceptConn *net.UnixConn + acceptErr error + closed bool + acceptFn func() (*net.UnixConn, error) + deadlineFn func(time.Time) error +} + +// fakeListener is a stub for unixListener so tests can drive +// accept/deadline paths without creating a real filesystem-backed Unix socket. +func (f *fakeListener) AcceptUnix() (*net.UnixConn, error) { + if f.acceptFn != nil { + return f.acceptFn() + } + return f.acceptConn, f.acceptErr +} + +func (f *fakeListener) SetDeadline(t time.Time) error { + if f.deadlineFn != nil { + return f.deadlineFn(t) + } + _ = t + return nil +} + +func (f *fakeListener) Close() error { + f.closed = true + return nil +} + +type fakeIPCConn struct{} + +func (f *fakeIPCConn) ReadFrame(ctx context.Context) ([]byte, error) { + _ = ctx + return nil, nil +} + +func (f *fakeIPCConn) WriteFrame(ctx context.Context, data []byte) error { + _ = ctx + _ = data + return nil +} + +func (f *fakeIPCConn) Close() error { return nil } + +type timeoutErr struct{} + +func (timeoutErr) Error() string { return "timeout" } +func (timeoutErr) Timeout() bool { return true } +func (timeoutErr) Temporary() bool { return true } + +type readStep struct { + data []byte + err error +} + +type scriptedConn struct { + steps []readStep + index int +} + +func (c *scriptedConn) Read(p []byte) (int, error) { + if c.index >= len(c.steps) { + return 0, io.EOF + } + step := c.steps[c.index] + c.index++ + n := copy(p, step.data) + return n, step.err +} + +func (c *scriptedConn) Write(p []byte) (int, error) { return len(p), nil } +func (c *scriptedConn) Close() error { return nil } +func (c *scriptedConn) LocalAddr() net.Addr { return dummyAddr("local") } +func (c *scriptedConn) RemoteAddr() net.Addr { return dummyAddr("remote") } +func (c *scriptedConn) SetDeadline(time.Time) error { return nil } +func (c *scriptedConn) SetReadDeadline(time.Time) error { return nil } +func (c *scriptedConn) SetWriteDeadline(time.Time) error { return nil } + +type dummyAddr string + +func (a dummyAddr) Network() string { return "test" } +func (a dummyAddr) String() string { return string(a) } + +func mustUnixServer(t *testing.T, path string, opts ...Option) *UnixServer { + t.Helper() + srvAny, err := NewUnixServer(path, opts...) + if err != nil { + t.Fatalf("NewUnixServer failed: %v", err) + } + return srvAny.(*UnixServer) +} + +func TestNewUnixServer(t *testing.T) { + tests := []struct { + name string + path string + wantErr error + }{ + {name: "empty path", path: "", wantErr: ErrSocketPathRequired}, + {name: "valid path", path: "/tmp/elevate.sock", wantErr: nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewUnixServer(tt.path) + if tt.wantErr == nil { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil { + t.Fatal("expected non-nil server") + } + return + } + + if !errors.Is(err, tt.wantErr) { + t.Fatalf("unexpected error: got %v want %v", err, tt.wantErr) + } + }) + } +} + +func TestNewUnixServerRejectsInvalidMaxFrame(t *testing.T) { + _, err := NewUnixServer("/tmp/elevate.sock", WithMaxFrameBytes(0)) + if err == nil { + t.Fatal("expected error for non-positive max frame size") + } +} + +func TestRemoveStaleSocket(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, srv *UnixServer) string + wantErr bool + }{ + { + name: "missing path", + setup: func(t *testing.T, srv *UnixServer) string { + _ = srv + return filepath.Join(t.TempDir(), "does-not-exist.sock") + }, + wantErr: false, + }, + { + name: "reject non-socket file", + setup: func(t *testing.T, srv *UnixServer) string { + _ = srv + path := filepath.Join(t.TempDir(), "not-a-socket") + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatalf("write file failed: %v", err) + } + return path + }, + wantErr: true, + }, + } + + srv := mustUnixServer(t, "/tmp/elevate.sock") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := tt.setup(t, srv) + err := srv.removeStaleSocket(path) + if tt.wantErr && err == nil { + t.Fatal("expected error") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestNormalizeReadFrame(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "trim CRLF", in: "hello\r\n", want: "hello"}, + {name: "trim LF", in: "hello\n", want: "hello"}, + {name: "no newline", in: "hello", want: "hello"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeReadFrame([]byte(tt.in)) + if string(got) != tt.want { + t.Fatalf("unexpected normalized frame: got %q want %q", string(got), tt.want) + } + }) + } +} + +func TestNormalizeWriteFrame(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "append newline", in: "hello", want: "hello\n"}, + {name: "keep newline", in: "hello\n", want: "hello\n"}, + {name: "empty frame", in: "", want: "\n"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeWriteFrame([]byte(tt.in)) + if string(got) != tt.want { + t.Fatalf("unexpected normalized frame: got %q want %q", string(got), tt.want) + } + }) + } +} + +func TestNormalizeWriteFrameDoesNotMutateInput(t *testing.T) { + src := make([]byte, 5, 8) + copy(src, []byte("hello")) + + got := normalizeWriteFrame(src) + if string(got) != "hello\n" { + t.Fatalf("unexpected normalized frame: got %q want %q", string(got), "hello\\n") + } + if string(src) != "hello" { + t.Fatalf("input slice was mutated: got %q want %q", string(src), "hello") + } +} + +func TestReadFramePreservesPartialDataAcrossTimeouts(t *testing.T) { + conn := &scriptedConn{ + steps: []readStep{ + {data: []byte("hello"), err: timeoutErr{}}, + {data: []byte(" world\n"), err: nil}, + }, + } + + frame, err := (&unixConn{ + conn: conn, + reader: bufio.NewReaderSize(conn, defaultMaxFrameBytes+1), + max: defaultMaxFrameBytes, + }).ReadFrame(context.Background()) + if err != nil { + t.Fatalf("ReadFrame failed: %v", err) + } + if string(frame) != "hello world" { + t.Fatalf("unexpected frame: got %q want %q", string(frame), "hello world") + } +} + +func TestReadFrameReturnsCopiedBytes(t *testing.T) { + conn := &scriptedConn{ + steps: []readStep{ + {data: []byte("first\nsecond\n"), err: nil}, + }, + } + + ipcConn := &unixConn{ + conn: conn, + reader: bufio.NewReaderSize(conn, defaultMaxFrameBytes+1), + max: defaultMaxFrameBytes, + } + first, err := ipcConn.ReadFrame(context.Background()) + if err != nil { + t.Fatalf("first ReadFrame failed: %v", err) + } + firstCopy := append([]byte(nil), first...) + + second, err := ipcConn.ReadFrame(context.Background()) + if err != nil { + t.Fatalf("second ReadFrame failed: %v", err) + } + if string(second) != "second" { + t.Fatalf("unexpected second frame: got %q want %q", string(second), "second") + } + if !bytes.Equal(first, firstCopy) { + t.Fatalf("first frame mutated after second read: got %q want %q", string(first), string(firstCopy)) + } +} + +func TestStartReturnsMkdirError(t *testing.T) { + srv := mustUnixServer(t, filepath.Join(t.TempDir(), "elevate.sock"), + WithMkdirAll(func(path string, perm os.FileMode) error { + _ = path + _ = perm + return errors.New("mkdir failed") + }), + ) + + if err := srv.Start(context.Background()); err == nil { + t.Fatal("expected Start to fail") + } +} + +func TestStartReturnsListenError(t *testing.T) { + srv := mustUnixServer(t, filepath.Join(t.TempDir(), "elevate.sock"), + WithListenUnix(func(network string, laddr *net.UnixAddr) (unixListener, error) { + _ = network + _ = laddr + return nil, errors.New("listen failed") + }), + ) + + if err := srv.Start(context.Background()); err == nil { + t.Fatal("expected Start to fail") + } +} + +func TestStartChmodFailureClosesListener(t *testing.T) { + fl := &fakeListener{} + srv := mustUnixServer(t, filepath.Join(t.TempDir(), "elevate.sock"), + WithListenUnix(func(network string, laddr *net.UnixAddr) (unixListener, error) { + _ = network + _ = laddr + return fl, nil + }), + WithChmod(func(path string, mode os.FileMode) error { + _ = path + _ = mode + return errors.New("chmod failed") + }), + ) + + if err := srv.Start(context.Background()); err == nil { + t.Fatal("expected Start to fail") + } + if !fl.closed { + t.Fatal("expected listener to be closed on chmod failure") + } + if srv.listener != nil { + t.Fatal("expected listener to be cleared on chmod failure") + } +} + +func TestStartAppliesSocketGroupOwnership(t *testing.T) { + fl := &fakeListener{} + var chownCalls int + var sawDir bool + var sawSocket bool + socketPath := filepath.Join(t.TempDir(), "elevate.sock") + + srv := mustUnixServer(t, socketPath, + WithSocketGroup("thand"), + WithLookupGroup(func(groupname string) (*osuser.Group, error) { + if groupname != "thand" { + t.Fatalf("unexpected group lookup: %s", groupname) + } + return &osuser.Group{Name: "thand", Gid: "1234"}, nil + }), + WithListenUnix(func(network string, laddr *net.UnixAddr) (unixListener, error) { + _ = network + _ = laddr + return fl, nil + }), + WithChmod(func(path string, mode os.FileMode) error { + _ = path + _ = mode + return nil + }), + WithChown(func(name string, uid, gid int) error { + chownCalls++ + if uid != -1 || gid != 1234 { + t.Fatalf("unexpected chown ownership uid=%d gid=%d", uid, gid) + } + if name == filepath.Dir(socketPath) { + sawDir = true + } + if name == socketPath { + sawSocket = true + } + return nil + }), + ) + + if err := srv.Start(context.Background()); err != nil { + t.Fatalf("Start failed: %v", err) + } + if chownCalls != 2 || !sawDir || !sawSocket { + t.Fatalf("unexpected chown calls: count=%d dir=%v socket=%v", chownCalls, sawDir, sawSocket) + } +} + +func TestStartSocketDirChownFailure(t *testing.T) { + socketPath := filepath.Join(t.TempDir(), "elevate.sock") + srv := mustUnixServer(t, socketPath, + WithSocketGroup("thand"), + WithLookupGroup(func(groupname string) (*osuser.Group, error) { + _ = groupname + return &osuser.Group{Name: "thand", Gid: "1234"}, nil + }), + WithChown(func(name string, uid, gid int) error { + _ = name + _ = uid + _ = gid + return errors.New("chown dir failed") + }), + ) + if err := srv.Start(context.Background()); err == nil { + t.Fatal("expected Start to fail") + } +} + +func TestStartSocketChownFailureClosesListener(t *testing.T) { + fl := &fakeListener{} + socketPath := filepath.Join(t.TempDir(), "elevate.sock") + srv := mustUnixServer(t, socketPath, + WithSocketGroup("thand"), + WithLookupGroup(func(groupname string) (*osuser.Group, error) { + _ = groupname + return &osuser.Group{Name: "thand", Gid: "1234"}, nil + }), + WithListenUnix(func(network string, laddr *net.UnixAddr) (unixListener, error) { + _ = network + _ = laddr + return fl, nil + }), + WithChown(func(name string, uid, gid int) error { + _ = uid + _ = gid + if name == socketPath { + return errors.New("chown socket failed") + } + return nil + }), + ) + if err := srv.Start(context.Background()); err == nil { + t.Fatal("expected Start to fail") + } + if !fl.closed { + t.Fatal("expected listener to close on socket chown failure") + } + if srv.listener != nil { + t.Fatal("expected listener to be cleared on socket chown failure") + } +} + +func TestCloseReturnsRemoveError(t *testing.T) { + srv := mustUnixServer(t, "/tmp/elevate.sock", + WithRemove(func(path string) error { + _ = path + return errors.New("remove failed") + }), + ) + + if err := srv.Close(); err == nil { + t.Fatal("expected Close to fail on remove error") + } +} + +func TestHandleAcceptResult(t *testing.T) { + cancelledCtx, cancel := context.WithCancel(context.Background()) + cancel() + + tests := []struct { + name string + ctx context.Context + res acceptResult + wantErr bool + errIs error + errContain string + }{ + { + name: "generic error gets wrapped", + ctx: context.Background(), + res: acceptResult{err: errors.New("boom")}, + wantErr: true, + errContain: "accept unix connection", + }, + { + name: "closed listener with canceled context returns canceled", + ctx: cancelledCtx, + res: acceptResult{err: net.ErrClosed}, + wantErr: true, + errIs: context.Canceled, + }, + { + name: "closed listener without canceled context returns net.ErrClosed", + ctx: context.Background(), + res: acceptResult{err: net.ErrClosed}, + wantErr: true, + errIs: net.ErrClosed, + }, + } + + srv := mustUnixServer(t, "/tmp/elevate.sock") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := srv.handleAcceptResult(tt.ctx, tt.res) + if tt.wantErr && err == nil { + t.Fatal("expected error") + } + if !tt.wantErr && err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.errIs != nil && !errors.Is(err, tt.errIs) { + t.Fatalf("unexpected error: got %v want errors.Is(...,%v)=true", err, tt.errIs) + } + if tt.errContain != "" && (err == nil || !strings.Contains(err.Error(), tt.errContain)) { + t.Fatalf("expected error containing %q, got %v", tt.errContain, err) + } + }) + } +} + +func TestAcceptReturnsErrServerNotStarted(t *testing.T) { + srv := mustUnixServer(t, "/tmp/elevate.sock") + + _, err := srv.Accept(context.Background()) + if !errors.Is(err, ErrServerNotStarted) { + t.Fatalf("expected ErrServerNotStarted, got: %v", err) + } +} + +func TestAcceptSuccessReturnsConn(t *testing.T) { + srv := mustUnixServer(t, "/tmp/elevate.sock", + WithNewConn(func(conn *net.UnixConn) handler.IPCConn { + _ = conn + return &fakeIPCConn{} + }), + ) + + fl := &fakeListener{acceptConn: nil, acceptErr: nil} + srv.listener = fl + + conn, err := srv.Accept(context.Background()) + if err != nil { + t.Fatalf("Accept failed: %v", err) + } + if conn == nil { + t.Fatal("expected non-nil connection") + } +} + +func TestAcceptCanceledContextUnblocksAccept(t *testing.T) { + // Keep this as a standalone scenario: it validates cancellation choreography + // between SetDeadline-based unblock and the in-flight accept goroutine. + release := make(chan struct{}) + fl := &fakeListener{ + acceptFn: func() (*net.UnixConn, error) { + <-release + return nil, net.ErrClosed + }, + deadlineFn: func(t time.Time) error { + _ = t + close(release) + return nil + }, + } + + srv := mustUnixServer(t, "/tmp/elevate.sock") + srv.listener = fl + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := srv.Accept(ctx) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got: %v", err) + } +} diff --git a/cmd/elevate/main.go b/cmd/elevate/main.go new file mode 100644 index 00000000..1278505d --- /dev/null +++ b/cmd/elevate/main.go @@ -0,0 +1,217 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "runtime" + "syscall" + "time" + + "github.com/thand-io/agent/cmd/elevate/clock" + elevateconfig "github.com/thand-io/agent/cmd/elevate/config" + "github.com/thand-io/agent/cmd/elevate/grant" + "github.com/thand-io/agent/cmd/elevate/handler" + "github.com/thand-io/agent/cmd/elevate/ipc" + "github.com/thand-io/agent/cmd/elevate/state" + "github.com/thand-io/agent/cmd/elevate/verify" +) + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + deps, err := buildDependencies() + if err != nil { + logger.Error("failed to build dependencies", "err", err) + os.Exit(1) + } + if deps.logger != nil { + logger = deps.logger + } + + logger.Info("elevate helper starting", + "component", "elevate_main", + "socket_path", deps.cfg.SocketPath, + "state_path", deps.cfg.StatePath, + "cleanup_interval", deps.cfg.CleanupInterval.String(), + "state_retention", deps.cfg.StateRetention.String(), + "request_timeout", deps.cfg.RequestTimeout.String(), + "socket_user", deps.cfg.SocketUser, + "socket_group", deps.cfg.SocketGroup, + "log_level", deps.cfg.LogLevel, + ) + + if err := run(ctx, deps); err != nil { + logger.Error("elevate server error", "err", err) + os.Exit(1) + } +} + +type dependencies struct { + ipc handler.IPCServer + handler *handler.Handler + cleanup *CleanupRunner + logger *slog.Logger + cfg *elevateconfig.Config +} + +type platformDependencies struct { + ipc handler.IPCServer + grantEngine handler.GrantEngine + clock handler.Clock +} + +func buildDependencies() (*dependencies, error) { + cfg, err := elevateconfig.LoadFromEnv() + if err != nil { + return nil, err + } + logLevel, err := elevateconfig.ParseLogLevel(cfg.LogLevel) + if err != nil { + return nil, err + } + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: logLevel})) + + platformDeps, err := buildPlatformDependencies(cfg) + if err != nil { + return nil, err + } + + verifier, err := verify.NewVerifier() + if err != nil { + return nil, err + } + stateStore, err := state.NewFileStore(cfg.StatePath) + if err != nil { + return nil, err + } + + router := handler.New( + platformDeps.grantEngine, + verifier, + stateStore, + platformDeps.clock, + handler.WithLogger(logger), + handler.WithRequestTimeout(cfg.RequestTimeout), + ) + cleanupRunner, err := NewCleanupRunner(stateStore, platformDeps.grantEngine, platformDeps.clock, cfg.CleanupInterval, cfg.StateRetention, logger) + if err != nil { + return nil, err + } + + return &dependencies{ + ipc: platformDeps.ipc, + handler: router, + cleanup: cleanupRunner, + logger: logger, + cfg: cfg, + }, nil +} + +func buildPlatformDependencies(cfg *elevateconfig.Config) (*platformDependencies, error) { + switch runtime.GOOS { + case "linux": + return buildLinuxPlatformDependencies(cfg) + case "darwin": + return nil, fmt.Errorf("unsupported operating system %q: darwin implementation not added yet", runtime.GOOS) + case "windows": + return buildWindowsPlatformDependencies(cfg) + default: + return nil, fmt.Errorf("unsupported operating system %q", runtime.GOOS) + } +} + +func buildLinuxPlatformDependencies(cfg *elevateconfig.Config) (*platformDependencies, error) { + ipcOpts := []ipc.Option{} + if cfg.SocketUser != "" { + ipcOpts = append(ipcOpts, ipc.WithSocketUser(cfg.SocketUser)) + } + if cfg.SocketGroup != "" { + ipcOpts = append(ipcOpts, ipc.WithSocketGroup(cfg.SocketGroup)) + } + ipcServer, err := ipc.NewUnixServer(cfg.SocketPath, ipcOpts...) + if err != nil { + return nil, err + } + + grantEngine, err := grant.NewLinuxEngine(grant.LinuxEngineConfig{ + SudoersDir: cfg.SudoersDir, + SudoersFile: cfg.SudoersFile, + VisudoBin: cfg.VisudoBin, + }) + if err != nil { + return nil, err + } + + return &platformDependencies{ + ipc: ipcServer, + grantEngine: grantEngine, + clock: clock.NewClock(), + }, nil +} + +func buildWindowsPlatformDependencies(cfg *elevateconfig.Config) (*platformDependencies, error) { + ipcOpts := []ipc.Option{} + if cfg.SocketUser != "" { + ipcOpts = append(ipcOpts, ipc.WithSocketUser(cfg.SocketUser)) + } + ipcServer, err := ipc.NewUnixServer(cfg.SocketPath, ipcOpts...) + if err != nil { + return nil, err + } + + grantEngine, err := grant.NewWindowsEngine(grant.WindowsEngineConfig{ + AdminGroup: cfg.WindowsAdminGroup, + }) + if err != nil { + return nil, err + } + + return &platformDependencies{ + ipc: ipcServer, + grantEngine: grantEngine, + clock: clock.NewClock(), + }, nil +} + +func run(ctx context.Context, deps *dependencies) error { + if deps == nil { + return fmt.Errorf("dependencies are required") + } + if deps.cleanup == nil { + return fmt.Errorf("cleanup runner is required") + } + + server := NewServer(deps.ipc, deps.handler) + + serverErr := make(chan error, 1) + go func() { + serverErr <- server.Run(ctx) + }() + + cleanupErr := make(chan error, 1) + go func() { + cleanupErr <- deps.cleanup.Run(ctx) + }() + + select { + case err := <-serverErr: + return err + case err := <-cleanupErr: + return err + case <-ctx.Done(): + // Give goroutines a brief chance to observe cancellation and stop. + select { + case err := <-serverErr: + return err + case err := <-cleanupErr: + return err + case <-time.After(250 * time.Millisecond): + return nil + } + } +} diff --git a/cmd/elevate/server.go b/cmd/elevate/server.go new file mode 100644 index 00000000..c86625bc --- /dev/null +++ b/cmd/elevate/server.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + "errors" + + "github.com/thand-io/agent/cmd/elevate/handler" +) + +// Server coordinates IPC accept loop and delegates per-connection handling. +type Server struct { + ipc handler.IPCServer + handler *handler.Handler +} + +func NewServer(ipc handler.IPCServer, h *handler.Handler) *Server { + return &Server{ipc: ipc, handler: h} +} + +func (s *Server) Run(ctx context.Context) error { + if s.ipc == nil { + return errors.New("ipc server is required") + } + if s.handler == nil { + return errors.New("handler is required") + } + + if err := s.ipc.Start(ctx); err != nil { + return err + } + defer s.ipc.Close() + + for { + if err := ctx.Err(); err != nil { + return nil + } + + conn, err := s.ipc.Accept(ctx) + if err != nil { + if ctx.Err() != nil { + return nil + } + return err + } + + if err := s.handler.HandleConnection(ctx, conn); err != nil { + _ = conn.Close() + // Chunk 1: no logging/error policy yet. + continue + } + + _ = conn.Close() + } +} diff --git a/cmd/elevate/state/store.go b/cmd/elevate/state/store.go new file mode 100644 index 00000000..d5e2f29d --- /dev/null +++ b/cmd/elevate/state/store.go @@ -0,0 +1,191 @@ +package state + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/thand-io/agent/cmd/elevate/domain" + "github.com/thand-io/agent/cmd/elevate/handler" +) + +// stateSchemaVersion guards on-disk compatibility for the state file. +const stateSchemaVersion = 1 + +// fileState is the on-disk JSON representation for persisted grants. +type fileState struct { + Version int `json:"version"` + Grants []domain.GrantState `json:"grants"` +} + +// FileStore persists active grants to a local JSON file using atomic writes. +type FileStore struct { + path string + mu sync.Mutex +} + +// NewFileStore constructs a StateStore backed by a single JSON file. +func NewFileStore(path string) (handler.StateStore, error) { + if strings.TrimSpace(path) == "" { + return nil, errors.New("state path is required") + } + + return &FileStore{path: path}, nil +} + +// Put upserts a grant state by request ID and atomically persists it. +func (s *FileStore) Put(ctx context.Context, grant domain.GrantState) error { + if err := ctx.Err(); err != nil { + return err + } + if strings.TrimSpace(grant.RequestID) == "" { + return errors.New("request ID is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + st, err := s.readState() + if err != nil { + return err + } + + found := false + for i := range st.Grants { + if st.Grants[i].RequestID == grant.RequestID { + st.Grants[i] = grant + found = true + break + } + } + if !found { + st.Grants = append(st.Grants, grant) + } + + return s.writeState(st) +} + +// Delete removes a grant state by request ID and atomically persists. +func (s *FileStore) Delete(ctx context.Context, requestID string) error { + if err := ctx.Err(); err != nil { + return err + } + if requestID == "" { + return errors.New("request ID is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + st, err := s.readState() + if err != nil { + return err + } + + filtered := st.Grants[:0] + for _, g := range st.Grants { + if g.RequestID != requestID { + filtered = append(filtered, g) + } + } + st.Grants = filtered + + return s.writeState(st) +} + +// List returns all persisted grant states. +func (s *FileStore) List(ctx context.Context) ([]domain.GrantState, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + + s.mu.Lock() + defer s.mu.Unlock() + + st, err := s.readState() + if err != nil { + return nil, err + } + + out := make([]domain.GrantState, len(st.Grants)) + copy(out, st.Grants) + return out, nil +} + +// readState loads and validates the on-disk state payload. +func (s *FileStore) readState() (*fileState, error) { + b, err := os.ReadFile(s.path) + if errors.Is(err, os.ErrNotExist) { + return &fileState{Version: stateSchemaVersion, Grants: []domain.GrantState{}}, nil + } + if err != nil { + return nil, fmt.Errorf("read state file: %w", err) + } + + var st fileState + if err := json.Unmarshal(b, &st); err != nil { + return nil, fmt.Errorf("decode state file: %w", err) + } + if st.Version != stateSchemaVersion { + return nil, fmt.Errorf("unsupported state version: %d", st.Version) + } + if st.Grants == nil { + st.Grants = []domain.GrantState{} + } + return &st, nil +} + +// writeState persists state using temp-file + fsync + rename for atomic updates. +func (s *FileStore) writeState(st *fileState) error { + st.Version = stateSchemaVersion + + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("create state directory: %w", err) + } + + b, err := json.Marshal(st) + if err != nil { + return fmt.Errorf("encode state file: %w", err) + } + + tmpFile, err := os.CreateTemp(dir, ".state-*.tmp") + if err != nil { + return fmt.Errorf("create temp state file: %w", err) + } + tmpPath := tmpFile.Name() + keepTmp := true + defer func() { + if keepTmp { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmpFile.Write(b); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("write temp state file: %w", err) + } + if err := tmpFile.Sync(); err != nil { + _ = tmpFile.Close() + return fmt.Errorf("sync temp state file: %w", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("close temp state file: %w", err) + } + + if err := os.Rename(tmpPath, s.path); err != nil { + return fmt.Errorf("rename temp state file: %w", err) + } + keepTmp = false + + if err := syncStateDir(dir); err != nil { + return fmt.Errorf("sync state directory: %w", err) + } + + return nil +} diff --git a/cmd/elevate/state/store_test.go b/cmd/elevate/state/store_test.go new file mode 100644 index 00000000..ce02d7ee --- /dev/null +++ b/cmd/elevate/state/store_test.go @@ -0,0 +1,110 @@ +package state + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" +) + +func TestFileStorePutListDeleteAndRecovery(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + + storeA, err := NewFileStore(path) + if err != nil { + t.Fatalf("NewFileStore failed: %v", err) + } + + grant := domain.GrantState{ + RequestID: "req-1", + WorkflowID: "wf-1", + Username: "alice", + GrantedAtWallUTC: time.Date(2026, 2, 22, 10, 0, 0, 0, time.UTC), + GrantedAtMonoNS: 100, + DurationSeconds: 60, + WasAlreadyPrivileged: false, + } + + if err := storeA.Put(context.Background(), grant); err != nil { + t.Fatalf("Put failed: %v", err) + } + + got, err := storeA.List(context.Background()) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(got) != 1 || got[0].RequestID != "req-1" { + t.Fatalf("unexpected list after put: %+v", got) + } + + // Simulate process restart by creating a new store instance on same path. + storeB, err := NewFileStore(path) + if err != nil { + t.Fatalf("NewFileStore (recovery) failed: %v", err) + } + recovered, err := storeB.List(context.Background()) + if err != nil { + t.Fatalf("List after recovery failed: %v", err) + } + if len(recovered) != 1 || recovered[0].RequestID != "req-1" { + t.Fatalf("unexpected recovered state: %+v", recovered) + } + + if err := storeB.Delete(context.Background(), "req-1"); err != nil { + t.Fatalf("Delete failed: %v", err) + } + empty, err := storeB.List(context.Background()) + if err != nil { + t.Fatalf("List after delete failed: %v", err) + } + if len(empty) != 0 { + t.Fatalf("expected empty state after delete, got %+v", empty) + } +} + +func TestFileStoreDeleteMissingIsNoop(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + store, err := NewFileStore(path) + if err != nil { + t.Fatalf("NewFileStore failed: %v", err) + } + + if err := store.Delete(context.Background(), "missing"); err != nil { + t.Fatalf("Delete missing should be nil, got %v", err) + } +} + +func TestFileStoreDeleteRequiresExactRequestID(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.json") + store, err := NewFileStore(path) + if err != nil { + t.Fatalf("NewFileStore failed: %v", err) + } + + grant := domain.GrantState{ + RequestID: "req-1", + WorkflowID: "wf-1", + Username: "alice", + GrantedAtWallUTC: time.Date(2026, 2, 22, 10, 0, 0, 0, time.UTC), + GrantedAtMonoNS: 100, + DurationSeconds: 60, + WasAlreadyPrivileged: false, + } + if err := store.Put(context.Background(), grant); err != nil { + t.Fatalf("Put failed: %v", err) + } + + if err := store.Delete(context.Background(), " req-1 "); err != nil { + t.Fatalf("Delete with non-matching request ID should be nil, got %v", err) + } + + got, err := store.List(context.Background()) + if err != nil { + t.Fatalf("List failed: %v", err) + } + if len(got) != 1 || got[0].RequestID != "req-1" { + t.Fatalf("expected exact request ID match to be required, got %+v", got) + } +} diff --git a/cmd/elevate/state/sync_dir_unix.go b/cmd/elevate/state/sync_dir_unix.go new file mode 100644 index 00000000..38d4fdff --- /dev/null +++ b/cmd/elevate/state/sync_dir_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package state + +import "os" + +func syncStateDir(dir string) error { + dirFD, err := os.Open(dir) + if err != nil { + return err + } + defer dirFD.Close() + return dirFD.Sync() +} diff --git a/cmd/elevate/state/sync_dir_windows.go b/cmd/elevate/state/sync_dir_windows.go new file mode 100644 index 00000000..cf602a39 --- /dev/null +++ b/cmd/elevate/state/sync_dir_windows.go @@ -0,0 +1,9 @@ +//go:build windows + +package state + +func syncStateDir(string) error { + // Windows directory handles are not fsync'd the same way as Unix here. + // Keep atomic temp-file rename behavior and skip directory sync. + return nil +} diff --git a/cmd/elevate/tools/generate_test_key/main.go b/cmd/elevate/tools/generate_test_key/main.go new file mode 100644 index 00000000..c8975265 --- /dev/null +++ b/cmd/elevate/tools/generate_test_key/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "flag" + "fmt" + "os" + "path/filepath" +) + +func main() { + var ( + outDir string + keyID string + overwrite bool + ) + + flag.StringVar(&outDir, "out-dir", ".", "output directory for generated key files") + flag.StringVar(&keyID, "key-id", "manual-test-key", "key id used for file names") + flag.BoolVar(&overwrite, "overwrite", false, "overwrite existing files") + flag.Parse() + + if keyID == "" { + exitf("key-id cannot be empty") + } + if err := os.MkdirAll(outDir, 0o755); err != nil { + exitf("create output directory: %v", err) + } + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + exitf("generate keypair: %v", err) + } + + pubDER, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + exitf("marshal public key: %v", err) + } + privDER, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + exitf("marshal private key: %v", err) + } + + publicPath := filepath.Join(outDir, keyID+".pem") + privatePath := filepath.Join(outDir, keyID+".private.pem") + + if err := writePEMFile(publicPath, "PUBLIC KEY", pubDER, 0o644, overwrite); err != nil { + exitf("write public key: %v", err) + } + if err := writePEMFile(privatePath, "PRIVATE KEY", privDER, 0o600, overwrite); err != nil { + exitf("write private key: %v", err) + } + + fmt.Printf("generated public key: %s\n", publicPath) + fmt.Printf("generated private key: %s\n", privatePath) +} + +func writePEMFile(path, blockType string, der []byte, mode os.FileMode, overwrite bool) error { + flags := os.O_WRONLY | os.O_CREATE + if overwrite { + flags |= os.O_TRUNC + } else { + flags |= os.O_EXCL + } + + f, err := os.OpenFile(path, flags, mode) + if err != nil { + return err + } + defer f.Close() + + if err := pem.Encode(f, &pem.Block{Type: blockType, Bytes: der}); err != nil { + return err + } + return f.Close() +} + +func exitf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(2) +} diff --git a/cmd/elevate/tools/sign_request/main.go b/cmd/elevate/tools/sign_request/main.go new file mode 100644 index 00000000..48a66b23 --- /dev/null +++ b/cmd/elevate/tools/sign_request/main.go @@ -0,0 +1,215 @@ +package main + +import ( + "bufio" + "bytes" + "crypto/ed25519" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "flag" + "fmt" + "net" + "os" + "time" + + "github.com/thand-io/agent/cmd/elevate/domain" + "github.com/thand-io/agent/cmd/elevate/verify" +) + +func main() { + var ( + privateKeyPath string + keyID string + nonce string + socketPath string + action string + workflowID string + requestID string + username string + durationSeconds int64 + timeout time.Duration + ) + + flag.StringVar(&privateKeyPath, "private-key", "", "path to Ed25519 private key PEM (PKCS8)") + flag.StringVar(&keyID, "key-id", "", "key ID to put in signed_response") + flag.StringVar(&nonce, "nonce", "", "challenge nonce from elevate helper") + flag.StringVar(&socketPath, "socket", "", "optional unix socket path; when set, performs full challenge/response flow") + flag.StringVar(&action, "action", "grant", "request action: grant|revoke") + flag.StringVar(&workflowID, "workflow-id", "", "workflow_id") + flag.StringVar(&requestID, "request-id", "", "request_id") + flag.StringVar(&username, "username", "", "username") + flag.Int64Var(&durationSeconds, "duration-seconds", 0, "duration_seconds (required for grant)") + flag.DurationVar(&timeout, "timeout", 10*time.Second, "socket read/write timeout when -socket is set") + flag.Parse() + + if privateKeyPath == "" || keyID == "" || workflowID == "" || requestID == "" || username == "" { + exitf("missing required flags") + } + if action != string(domain.ActionGrant) && action != string(domain.ActionRevoke) { + exitf("invalid action %q", action) + } + if action == string(domain.ActionGrant) && durationSeconds <= 0 { + exitf("duration-seconds must be > 0 for grant") + } + if socketPath == "" && nonce == "" { + exitf("nonce is required unless -socket is set") + } + + privateKey, err := loadEd25519PrivateKey(privateKeyPath) + if err != nil { + exitf("load private key: %v", err) + } + + request := domain.RequestFrame{ + Type: domain.FrameTypeRequest, + Action: domain.Action(action), + WorkflowID: workflowID, + RequestID: requestID, + Username: username, + DurationSeconds: durationSeconds, + } + if socketPath != "" { + if err := runSocketFlow(socketPath, timeout, request, keyID, nonce, privateKey); err != nil { + exitf("socket flow failed: %v", err) + } + return + } + + frame, err := buildSignedResponseFrame(request, nonce, keyID, privateKey) + if err != nil { + exitf("build signed response: %v", err) + } + + reqJSON, err := json.Marshal(request) + if err != nil { + exitf("marshal request: %v", err) + } + respJSON, err := json.Marshal(frame) + if err != nil { + exitf("marshal signed response: %v", err) + } + + // Print wire-ready frames in protocol order: request then signed_response. + fmt.Println(string(reqJSON)) + fmt.Println(string(respJSON)) +} + +func runSocketFlow(socketPath string, timeout time.Duration, req domain.RequestFrame, keyID string, expectedNonce string, privateKey ed25519.PrivateKey) error { + conn, err := net.DialTimeout("unix", socketPath, timeout) + if err != nil { + return fmt.Errorf("dial unix socket %q: %w", socketPath, err) + } + defer conn.Close() + + if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { + return fmt.Errorf("set deadline: %w", err) + } + + if err := writeFrame(conn, req); err != nil { + return fmt.Errorf("write request frame: %w", err) + } + + reader := bufio.NewReader(conn) + rawChallenge, err := readFrame(reader) + if err != nil { + return fmt.Errorf("read challenge frame: %w", err) + } + var challenge domain.ChallengeFrame + if err := json.Unmarshal(rawChallenge, &challenge); err != nil { + return fmt.Errorf("decode challenge frame: %w", err) + } + if challenge.Type != domain.FrameTypeChallenge { + return fmt.Errorf("unexpected first frame type %q", challenge.Type) + } + if expectedNonce != "" && expectedNonce != challenge.Nonce { + return fmt.Errorf("challenge nonce mismatch: expected %q got %q", expectedNonce, challenge.Nonce) + } + + signedResp, err := buildSignedResponseFrame(req, challenge.Nonce, keyID, privateKey) + if err != nil { + return fmt.Errorf("build signed response frame: %w", err) + } + if err := writeFrame(conn, signedResp); err != nil { + return fmt.Errorf("write signed response frame: %w", err) + } + + rawResult, err := readFrame(reader) + if err != nil { + return fmt.Errorf("read result frame: %w", err) + } + + // Print wire frames received from helper for easy manual inspection. + fmt.Println(string(rawChallenge)) + fmt.Println(string(rawResult)) + return nil +} + +func buildSignedResponseFrame(req domain.RequestFrame, nonce, keyID string, privateKey ed25519.PrivateKey) (domain.SignedResponseFrame, error) { + payload := verify.SignedPayload{ + Nonce: nonce, + Action: string(req.Action), + WorkflowID: req.WorkflowID, + RequestID: req.RequestID, + Username: req.Username, + DurationSeconds: req.DurationSeconds, + } + canonical, err := verify.CanonicalPayload(payload) + if err != nil { + return domain.SignedResponseFrame{}, fmt.Errorf("canonical payload: %w", err) + } + sig := ed25519.Sign(privateKey, canonical) + return domain.SignedResponseFrame{ + Type: domain.FrameTypeSignedResponse, + KeyID: keyID, + Signature: base64.StdEncoding.EncodeToString(sig), + SignedPayload: base64.StdEncoding.EncodeToString(canonical), + }, nil +} + +func writeFrame(conn net.Conn, frame any) error { + data, err := json.Marshal(frame) + if err != nil { + return err + } + data = append(data, '\n') + _, err = conn.Write(data) + return err +} + +func readFrame(reader *bufio.Reader) ([]byte, error) { + raw, err := reader.ReadBytes('\n') + if err != nil { + return nil, err + } + return bytes.TrimSpace(raw), nil +} + +func loadEd25519PrivateKey(path string) (ed25519.PrivateKey, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + block, _ := pem.Decode(raw) + if block == nil { + return nil, fmt.Errorf("invalid PEM data") + } + + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + + key, ok := parsed.(ed25519.PrivateKey) + if !ok { + return nil, fmt.Errorf("private key is not Ed25519") + } + return key, nil +} + +func exitf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(2) +} diff --git a/cmd/elevate/tools/windows_unix_socket_smoke/README.md b/cmd/elevate/tools/windows_unix_socket_smoke/README.md new file mode 100644 index 00000000..8b2086bf --- /dev/null +++ b/cmd/elevate/tools/windows_unix_socket_smoke/README.md @@ -0,0 +1,24 @@ +# Windows Unix Socket Smoke Test + +One-time pair of tools to verify Unix socket support on Windows with Go `net`. + +## Run + +In terminal 1: + +```powershell +cd cmd/elevate +go run ./tools/windows_unix_socket_smoke/server -socket "C:\temp\elevate-smoke.sock" +``` + +In terminal 2: + +```powershell +cd cmd/elevate +go run ./tools/windows_unix_socket_smoke/client -socket "C:\temp\elevate-smoke.sock" -message "hello" +``` + +Expected: +- server prints `received: "hello"` and exits +- client prints `reply: "ack:hello"` + diff --git a/cmd/elevate/tools/windows_unix_socket_smoke/client/main.go b/cmd/elevate/tools/windows_unix_socket_smoke/client/main.go new file mode 100644 index 00000000..bb9c7d55 --- /dev/null +++ b/cmd/elevate/tools/windows_unix_socket_smoke/client/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "net" + "os" + "strings" + "time" +) + +func main() { + var ( + socketPath string + message string + timeout time.Duration + ) + + flag.StringVar(&socketPath, "socket", `C:\temp\elevate-smoke.sock`, "unix socket path") + flag.StringVar(&message, "message", "ping", "line payload to send") + flag.DurationVar(&timeout, "timeout", 5*time.Second, "dial/read/write timeout") + flag.Parse() + + conn, err := net.DialTimeout("unix", socketPath, timeout) + if err != nil { + exitf("dial: %v", err) + } + defer conn.Close() + + if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil { + exitf("set deadline: %v", err) + } + + if _, err := fmt.Fprintf(conn, "%s\n", message); err != nil { + exitf("write: %v", err) + } + + reader := bufio.NewReader(conn) + reply, err := reader.ReadString('\n') + if err != nil { + exitf("read: %v", err) + } + + fmt.Printf("reply: %q\n", strings.TrimRight(reply, "\r\n")) +} + +func exitf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/cmd/elevate/tools/windows_unix_socket_smoke/server/main.go b/cmd/elevate/tools/windows_unix_socket_smoke/server/main.go new file mode 100644 index 00000000..5e644de3 --- /dev/null +++ b/cmd/elevate/tools/windows_unix_socket_smoke/server/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "bufio" + "flag" + "fmt" + "net" + "os" + "path/filepath" + "strings" +) + +func main() { + var socketPath string + flag.StringVar(&socketPath, "socket", `C:\temp\elevate-smoke.sock`, "unix socket path") + flag.Parse() + + if err := os.MkdirAll(filepath.Dir(socketPath), 0o755); err != nil { + exitf("create socket dir: %v", err) + } + _ = os.Remove(socketPath) + + ln, err := net.Listen("unix", socketPath) + if err != nil { + exitf("listen unix socket: %v", err) + } + defer func() { + _ = ln.Close() + _ = os.Remove(socketPath) + }() + + fmt.Printf("listening on %s\n", socketPath) + + conn, err := ln.Accept() + if err != nil { + exitf("accept: %v", err) + } + defer conn.Close() + + reader := bufio.NewReader(conn) + line, err := reader.ReadString('\n') + if err != nil { + exitf("read: %v", err) + } + line = strings.TrimRight(line, "\r\n") + fmt.Printf("received: %q\n", line) + + if _, err := fmt.Fprintf(conn, "ack:%s\n", line); err != nil { + exitf("write: %v", err) + } + fmt.Println("reply sent, exiting") +} + +func exitf(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + os.Exit(1) +} diff --git a/cmd/elevate/verify/key_parse.go b/cmd/elevate/verify/key_parse.go new file mode 100644 index 00000000..18d1abf3 --- /dev/null +++ b/cmd/elevate/verify/key_parse.go @@ -0,0 +1,64 @@ +package verify + +import ( + "crypto/ed25519" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "fmt" + "strings" +) + +func parseTrustedKeys(keys map[string]string) (map[string]ed25519.PublicKey, error) { + out := make(map[string]ed25519.PublicKey, len(keys)) + for keyID, keyText := range keys { + pk, err := parsePublicKeyText(keyText) + if err != nil { + return nil, fmt.Errorf("parse key %s: %w", keyID, err) + } + out[keyID] = pk + } + return out, nil +} + +func parsePublicKeyText(keyText string) (ed25519.PublicKey, error) { + trimmed := strings.TrimSpace(keyText) + if trimmed == "" { + return nil, fmt.Errorf("%w: empty key text", ErrInvalidPublicKey) + } + + if strings.HasPrefix(trimmed, "-----BEGIN ") { + return parsePEMEd25519PublicKey(trimmed) + } + + raw, err := base64.StdEncoding.DecodeString(trimmed) + if err != nil { + return nil, fmt.Errorf("%w: key is neither valid base64 nor PEM", ErrInvalidPublicKey) + } + if len(raw) != ed25519.PublicKeySize { + return nil, fmt.Errorf("%w: raw key must be %d bytes", ErrInvalidPublicKey, ed25519.PublicKeySize) + } + pk := make(ed25519.PublicKey, ed25519.PublicKeySize) + copy(pk, raw) + return pk, nil +} + +func parsePEMEd25519PublicKey(text string) (ed25519.PublicKey, error) { + block, _ := pem.Decode([]byte(text)) + if block == nil { + return nil, fmt.Errorf("%w: invalid PEM block", ErrInvalidPublicKey) + } + + pubAny, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("%w: parse PKIX public key: %v", ErrInvalidPublicKey, err) + } + + pub, ok := pubAny.(ed25519.PublicKey) + if !ok { + return nil, fmt.Errorf("%w: expected Ed25519", ErrUnsupportedKeyType) + } + pk := make(ed25519.PublicKey, len(pub)) + copy(pk, pub) + return pk, nil +} diff --git a/cmd/elevate/verify/key_parse_test.go b/cmd/elevate/verify/key_parse_test.go new file mode 100644 index 00000000..0341a3c6 --- /dev/null +++ b/cmd/elevate/verify/key_parse_test.go @@ -0,0 +1,97 @@ +package verify + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "testing" +) + +func TestParsePublicKeyText_Base64Valid(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + + text := base64.StdEncoding.EncodeToString(pub) + pk, err := parsePublicKeyText(text) + if err != nil { + t.Fatalf("parsePublicKeyText failed: %v", err) + } + if len(pk) != ed25519.PublicKeySize { + t.Fatalf("unexpected key length: got %d want %d", len(pk), ed25519.PublicKeySize) + } +} + +func TestParsePublicKeyText_PEMValidEd25519(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + t.Fatalf("MarshalPKIXPublicKey failed: %v", err) + } + pemText := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})) + + pk, err := parsePublicKeyText(pemText) + if err != nil { + t.Fatalf("parsePublicKeyText failed: %v", err) + } + if len(pk) != ed25519.PublicKeySize { + t.Fatalf("unexpected key length: got %d want %d", len(pk), ed25519.PublicKeySize) + } +} + +func TestParsePublicKeyText_Empty(t *testing.T) { + _, err := parsePublicKeyText(" ") + if !errors.Is(err, ErrInvalidPublicKey) { + t.Fatalf("expected ErrInvalidPublicKey, got: %v", err) + } +} + +func TestParsePublicKeyText_InvalidText(t *testing.T) { + _, err := parsePublicKeyText("not-a-key") + if !errors.Is(err, ErrInvalidPublicKey) { + t.Fatalf("expected ErrInvalidPublicKey, got: %v", err) + } +} + +func TestParsePublicKeyText_PEMWrongType(t *testing.T) { + rsaKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("Generate RSA key failed: %v", err) + } + + der, err := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey) + if err != nil { + t.Fatalf("MarshalPKIXPublicKey failed: %v", err) + } + pemText := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})) + + _, err = parsePublicKeyText(pemText) + if !errors.Is(err, ErrUnsupportedKeyType) { + t.Fatalf("expected ErrUnsupportedKeyType, got: %v", err) + } +} + +func TestParseTrustedKeys_Map(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + + keys := map[string]string{"k1": base64.StdEncoding.EncodeToString(pub)} + parsed, err := parseTrustedKeys(keys) + if err != nil { + t.Fatalf("parseTrustedKeys failed: %v", err) + } + if _, ok := parsed["k1"]; !ok { + t.Fatal("expected parsed key for k1") + } +} diff --git a/cmd/elevate/verify/keys/my-key-example.pem.example b/cmd/elevate/verify/keys/my-key-example.pem.example new file mode 100644 index 00000000..5316e4f4 --- /dev/null +++ b/cmd/elevate/verify/keys/my-key-example.pem.example @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa= +-----END PUBLIC KEY----- diff --git a/cmd/elevate/verify/keys_embedded.go b/cmd/elevate/verify/keys_embedded.go new file mode 100644 index 00000000..9f7d6e7b --- /dev/null +++ b/cmd/elevate/verify/keys_embedded.go @@ -0,0 +1,57 @@ +package verify + +import ( + "embed" + "errors" + "fmt" + "io/fs" + "path/filepath" + "strings" +) + +// Embedded key files are compile-time pinned key material. +// Values are PEM-encoded PKIX Ed25519 public keys. +// +//go:embed keys +var trustedKeyFiles embed.FS +var trustedKeysFS fs.FS = trustedKeyFiles + +func loadEmbeddedTrustedKeys() (map[string]string, error) { + entries, err := fs.ReadDir(trustedKeysFS, "keys") + if err != nil { + return nil, fmt.Errorf("read embedded keys directory: %w", err) + } + + out := make(map[string]string, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + if filepath.Ext(name) != ".pem" { + continue + } + + keyID := strings.TrimSuffix(name, ".pem") + if keyID == "" { + return nil, errors.New("embedded trusted key file has empty key id") + } + if _, exists := out[keyID]; exists { + return nil, fmt.Errorf("duplicate embedded key id: %s", keyID) + } + + raw, err := fs.ReadFile(trustedKeysFS, filepath.ToSlash(filepath.Join("keys", name))) + if err != nil { + return nil, fmt.Errorf("read embedded trusted key file %s: %w", name, err) + } + + keyText := strings.TrimSpace(string(raw)) + if keyText == "" { + return nil, errors.New("embedded trusted key file is empty for key id: " + keyID) + } + out[keyID] = keyText + } + + return out, nil +} diff --git a/cmd/elevate/verify/keys_embedded_test.go b/cmd/elevate/verify/keys_embedded_test.go new file mode 100644 index 00000000..9e241d47 --- /dev/null +++ b/cmd/elevate/verify/keys_embedded_test.go @@ -0,0 +1,101 @@ +package verify + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/pem" + "errors" + "io/fs" + "strings" + "testing" + "testing/fstest" +) + +func TestLoadEmbeddedTrustedKeys_EmptyWhenNoPEM(t *testing.T) { + setTrustedKeysFS(t, fstest.MapFS{ + "keys/my-key-example.pem.example": &fstest.MapFile{ + Data: []byte("example only"), + }, + }) + + keys, err := loadEmbeddedTrustedKeys() + if err != nil { + t.Fatalf("loadEmbeddedTrustedKeys failed: %v", err) + } + if len(keys) != 0 { + t.Fatalf("expected no trusted keys, got %d", len(keys)) + } +} + +func TestLoadEmbeddedTrustedKeys_LoadsPEM(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + t.Fatalf("MarshalPKIXPublicKey failed: %v", err) + } + pemText := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) + + setTrustedKeysFS(t, fstest.MapFS{ + "keys/local-test-key.pem": &fstest.MapFile{ + Data: pemText, + }, + "keys/ignored.pem.example": &fstest.MapFile{ + Data: []byte("ignored"), + }, + }) + + keys, err := loadEmbeddedTrustedKeys() + if err != nil { + t.Fatalf("loadEmbeddedTrustedKeys failed: %v", err) + } + if len(keys) != 1 { + t.Fatalf("expected 1 trusted key, got %d", len(keys)) + } + for keyID, keyText := range keys { + if strings.TrimSpace(keyText) == "" { + t.Fatalf("expected non-empty key text for %s", keyID) + } + if !strings.Contains(keyText, "BEGIN PUBLIC KEY") { + t.Fatalf("expected PEM public key format for %s", keyID) + } + key, err := parsePublicKeyText(keyText) + if err != nil { + t.Fatalf("expected parseable key for %s: %v", keyID, err) + } + if len(key) != ed25519.PublicKeySize { + t.Fatalf("unexpected public key size for %s: got %d", keyID, len(key)) + } + } +} + +func TestNewVerifier_RequiresTrustedKeys(t *testing.T) { + setTrustedKeysFS(t, fstest.MapFS{ + "keys/my-key-example.pem.example": &fstest.MapFile{ + Data: []byte("example only"), + }, + }) + + _, err := NewVerifier() + if err == nil { + t.Fatal("expected NewVerifier to fail without trusted keys") + } + if !errors.Is(err, ErrInvalidPublicKey) { + t.Fatalf("expected ErrInvalidPublicKey, got: %v", err) + } + if !strings.Contains(err.Error(), "no trusted keys configured") { + t.Fatalf("expected no trusted keys error, got: %v", err) + } +} + +func setTrustedKeysFS(t *testing.T, keyFS fs.FS) { + t.Helper() + original := trustedKeysFS + trustedKeysFS = keyFS + t.Cleanup(func() { + trustedKeysFS = original + }) +} diff --git a/cmd/elevate/verify/verifier.go b/cmd/elevate/verify/verifier.go new file mode 100644 index 00000000..acda01d0 --- /dev/null +++ b/cmd/elevate/verify/verifier.go @@ -0,0 +1,175 @@ +package verify + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + + "github.com/thand-io/agent/cmd/elevate/domain" +) + +const NonceSizeBytes = 32 + +var ( + ErrUnknownKeyID = errors.New("unknown key id") + ErrInvalidPublicKey = errors.New("invalid public key") + ErrInvalidSignature = errors.New("invalid signature") + ErrInvalidSignedData = errors.New("invalid signed payload") + ErrPayloadMismatch = errors.New("signed payload mismatch") + ErrUnsupportedKeyType = errors.New("unsupported public key type") +) + +// SignedPayload is the canonical signed data schema used by the verifier. +type SignedPayload struct { + Nonce string `json:"nonce"` + Action string `json:"action"` + WorkflowID string `json:"workflow_id"` + RequestID string `json:"request_id"` + Username string `json:"username"` + DurationSeconds int64 `json:"duration_seconds,omitempty"` +} + +type Verifier struct { + trustedKeys map[string]ed25519.PublicKey +} + +type Option func(*Verifier) error + +func WithTrustedKeys(keys map[string]ed25519.PublicKey) Option { + return func(v *Verifier) error { + cloned, err := cloneKeyMap(keys) + if err != nil { + return err + } + v.trustedKeys = cloned + return nil + } +} + +func WithTrustedKeysBase64(keys map[string]string) Option { + return func(v *Verifier) error { + parsed, err := parseTrustedKeys(keys) + if err != nil { + return err + } + v.trustedKeys = parsed + return nil + } +} + +func NewVerifier(opts ...Option) (*Verifier, error) { + embeddedKeys, err := loadEmbeddedTrustedKeys() + if err != nil { + return nil, err + } + + parsed, err := parseTrustedKeys(embeddedKeys) + if err != nil { + return nil, err + } + + v := &Verifier{trustedKeys: parsed} + for _, opt := range opts { + if err := opt(v); err != nil { + return nil, err + } + } + + if len(v.trustedKeys) == 0 { + return nil, fmt.Errorf("%w: no trusted keys configured", ErrInvalidPublicKey) + } + + return v, nil +} + +func (v *Verifier) Verify(keyID string, payload []byte, signature []byte) error { + key, ok := v.trustedKeys[keyID] + if !ok { + return fmt.Errorf("%w: %s", ErrUnknownKeyID, keyID) + } + + if !ed25519.Verify(key, payload, signature) { + return ErrInvalidSignature + } + + return nil +} + +func GenerateNonce() (string, error) { + buf := make([]byte, NonceSizeBytes) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate nonce: %w", err) + } + return base64.StdEncoding.EncodeToString(buf), nil +} + +func CanonicalPayload(payload SignedPayload) ([]byte, error) { + b, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal signed payload: %w", err) + } + return b, nil +} + +func DecodeSignedPayload(encoded string) (SignedPayload, error) { + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return SignedPayload{}, fmt.Errorf("decode payload: %w", err) + } + + var payload SignedPayload + if err := json.Unmarshal(raw, &payload); err != nil { + return SignedPayload{}, fmt.Errorf("%w: %v", ErrInvalidSignedData, err) + } + + return payload, nil +} + +func DecodeSignature(encoded string) ([]byte, error) { + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("decode signature: %w", err) + } + return raw, nil +} + +func MatchSignedPayload(req domain.RequestFrame, payload SignedPayload, nonce string) error { + if payload.Nonce != nonce { + return fmt.Errorf("%w: nonce mismatch", ErrPayloadMismatch) + } + if payload.Action != string(req.Action) { + return fmt.Errorf("%w: action mismatch", ErrPayloadMismatch) + } + if payload.WorkflowID != req.WorkflowID { + return fmt.Errorf("%w: workflow_id mismatch", ErrPayloadMismatch) + } + if payload.RequestID != req.RequestID { + return fmt.Errorf("%w: request_id mismatch", ErrPayloadMismatch) + } + if payload.Username != req.Username { + return fmt.Errorf("%w: username mismatch", ErrPayloadMismatch) + } + if payload.DurationSeconds != req.DurationSeconds { + return fmt.Errorf("%w: duration_seconds mismatch", ErrPayloadMismatch) + } + return nil +} + +func cloneKeyMap(in map[string]ed25519.PublicKey) (map[string]ed25519.PublicKey, error) { + out := make(map[string]ed25519.PublicKey, len(in)) + for k, v := range in { + if k == "" { + return nil, fmt.Errorf("%w: empty key id", ErrInvalidPublicKey) + } + if len(v) != ed25519.PublicKeySize { + return nil, fmt.Errorf("%w: key %s must be %d bytes", ErrInvalidPublicKey, k, ed25519.PublicKeySize) + } + pk := make(ed25519.PublicKey, len(v)) + copy(pk, v) + out[k] = pk + } + return out, nil +} diff --git a/cmd/elevate/verify/verifier_test.go b/cmd/elevate/verify/verifier_test.go new file mode 100644 index 00000000..89b33ab5 --- /dev/null +++ b/cmd/elevate/verify/verifier_test.go @@ -0,0 +1,290 @@ +package verify + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "strings" + "testing" + + "github.com/thand-io/agent/cmd/elevate/domain" +) + +const ( + testPubCurrentB64 = "A6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=" + testPubNextB64 = "C7w0aldmfDgBIL2cf9flHSxf3+o3zS9b9AWyxr9vLXg=" + testPayloadJSON = `{"nonce":"n","action":"grant","workflow_id":"wf","request_id":"r","username":"u","duration_seconds":60}` + testSigCurrentB64 = "5MXERshUkd6/ih7iPJEtHLvJ1XoP/O6q1Z3HBDhILaxWI4MyxYu7lhxgIG6I9z1BoDzp0/zMqUwF/XwM4KYFAw==" + testSigNextB64 = "+HeSNOrbUMwyisptpqdqZiXUPo7Ot0t7+A/syNv9ItCuaHcRvX41bJO3v9jQPBLCoy7GT8K/uLOr12di37RQAA==" +) + +func TestGenerateNonce(t *testing.T) { + nonce, err := GenerateNonce() + if err != nil { + t.Fatalf("GenerateNonce failed: %v", err) + } + raw, err := base64.StdEncoding.DecodeString(nonce) + if err != nil { + t.Fatalf("nonce is not valid base64: %v", err) + } + if len(raw) != NonceSizeBytes { + t.Fatalf("unexpected nonce size: got %d want %d", len(raw), NonceSizeBytes) + } +} + +func TestCanonicalPayloadIsDeterministic(t *testing.T) { + p := SignedPayload{Nonce: "n", Action: "grant", WorkflowID: "wf", RequestID: "r", Username: "u", DurationSeconds: 60} + a, err := CanonicalPayload(p) + if err != nil { + t.Fatalf("CanonicalPayload failed: %v", err) + } + b, err := CanonicalPayload(p) + if err != nil { + t.Fatalf("CanonicalPayload failed: %v", err) + } + if string(a) != string(b) { + t.Fatalf("canonical payload mismatch: %q vs %q", string(a), string(b)) + } +} + +func TestDecodeSignedPayload(t *testing.T) { + payload := SignedPayload{Nonce: "x", Action: "grant", WorkflowID: "wf", RequestID: "req", Username: "user", DurationSeconds: 10} + raw, err := CanonicalPayload(payload) + if err != nil { + t.Fatalf("CanonicalPayload failed: %v", err) + } + enc := base64.StdEncoding.EncodeToString(raw) + + decoded, err := DecodeSignedPayload(enc) + if err != nil { + t.Fatalf("DecodeSignedPayload failed: %v", err) + } + if decoded != payload { + t.Fatalf("decoded payload mismatch: got %+v want %+v", decoded, payload) + } +} + +func TestVerifierVerify(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + + v, err := NewVerifier(WithTrustedKeys(map[string]ed25519.PublicKey{"kid": pub})) + if err != nil { + t.Fatalf("NewVerifier failed: %v", err) + } + + payload := []byte("payload") + sig := ed25519.Sign(priv, payload) + + if err := v.Verify("kid", payload, sig); err != nil { + t.Fatalf("Verify failed: %v", err) + } + + if err := v.Verify("missing", payload, sig); !errors.Is(err, ErrUnknownKeyID) { + t.Fatalf("expected ErrUnknownKeyID, got: %v", err) + } + + bad := append([]byte(nil), sig...) + bad[0] ^= 0xff + if err := v.Verify("kid", payload, bad); !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("expected ErrInvalidSignature, got: %v", err) + } +} + +func TestMatchSignedPayload(t *testing.T) { + req := domain.RequestFrame{Action: domain.ActionGrant, WorkflowID: "wf", RequestID: "r", Username: "u", DurationSeconds: 123} + payload := SignedPayload{Nonce: "n", Action: "grant", WorkflowID: "wf", RequestID: "r", Username: "u", DurationSeconds: 123} + + if err := MatchSignedPayload(req, payload, "n"); err != nil { + t.Fatalf("MatchSignedPayload failed: %v", err) + } + + payload.Username = "other" + err := MatchSignedPayload(req, payload, "n") + if !errors.Is(err, ErrPayloadMismatch) { + t.Fatalf("expected ErrPayloadMismatch, got: %v", err) + } + if !strings.Contains(err.Error(), "username") { + t.Fatalf("expected mismatch detail, got: %v", err) + } +} + +func TestWithTrustedKeysBase64RejectsInvalidKey(t *testing.T) { + _, err := NewVerifier(WithTrustedKeysBase64(map[string]string{"k": "not-base64"})) + if err == nil { + t.Fatal("expected error for invalid base64 key") + } +} + +func TestNewVerifierWithTrustedKeys(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + v, err := NewVerifier(WithTrustedKeys(map[string]ed25519.PublicKey{"k": pub})) + if err != nil { + t.Fatalf("NewVerifier failed: %v", err) + } + if err := v.Verify("missing", []byte("p"), []byte("s")); !errors.Is(err, ErrUnknownKeyID) { + t.Fatalf("expected ErrUnknownKeyID, got: %v", err) + } +} + +func TestWithTrustedKeysRejectsInvalidKeyLength(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + + _, err = NewVerifier(WithTrustedKeys(map[string]ed25519.PublicKey{ + "k": pub[:len(pub)-1], + })) + if err == nil { + t.Fatal("expected error for invalid trusted key length") + } + if !errors.Is(err, ErrInvalidPublicKey) { + t.Fatalf("expected ErrInvalidPublicKey, got: %v", err) + } +} + +func TestWithTrustedKeysRejectsEmptyKeyID(t *testing.T) { + pub, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + + _, err = NewVerifier(WithTrustedKeys(map[string]ed25519.PublicKey{ + "": pub, + })) + if err == nil { + t.Fatal("expected error for empty trusted key id") + } + if !errors.Is(err, ErrInvalidPublicKey) { + t.Fatalf("expected ErrInvalidPublicKey, got: %v", err) + } +} + +func TestWithTrustedKeysSupportsPEM(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + + der, err := x509.MarshalPKIXPublicKey(pub) + if err != nil { + t.Fatalf("MarshalPKIXPublicKey failed: %v", err) + } + + pemText := string(pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: der, + })) + + v, err := NewVerifier(WithTrustedKeysBase64(map[string]string{"pem-key": pemText})) + if err != nil { + t.Fatalf("NewVerifier failed with PEM key: %v", err) + } + + payload := []byte("payload") + sig := ed25519.Sign(priv, payload) + if err := v.Verify("pem-key", payload, sig); err != nil { + t.Fatalf("Verify failed with PEM key: %v", err) + } +} + +func TestVerifySignatureVectors(t *testing.T) { + trusted := map[string]string{ + "thand-server-current": testPubCurrentB64, + "thand-server-next": testPubNextB64, + } + + v, err := NewVerifier(WithTrustedKeysBase64(trusted)) + if err != nil { + t.Fatalf("NewVerifier failed: %v", err) + } + + payload := []byte(testPayloadJSON) + sigCurrent, err := base64.StdEncoding.DecodeString(testSigCurrentB64) + if err != nil { + t.Fatalf("decode current signature failed: %v", err) + } + sigNext, err := base64.StdEncoding.DecodeString(testSigNextB64) + if err != nil { + t.Fatalf("decode next signature failed: %v", err) + } + + tamperedPayload := append([]byte(nil), payload...) + tamperedPayload[0] ^= 0x01 + + tamperedSig := append([]byte(nil), sigCurrent...) + tamperedSig[0] ^= 0x01 + + tests := []struct { + name string + keyID string + payload []byte + sig []byte + errIs error + }{ + { + name: "valid current key signature", + keyID: "thand-server-current", + payload: payload, + sig: sigCurrent, + }, + { + name: "valid next key signature", + keyID: "thand-server-next", + payload: payload, + sig: sigNext, + }, + { + name: "unknown key id", + keyID: "missing", + payload: payload, + sig: sigCurrent, + errIs: ErrUnknownKeyID, + }, + { + name: "tampered payload", + keyID: "thand-server-current", + payload: tamperedPayload, + sig: sigCurrent, + errIs: ErrInvalidSignature, + }, + { + name: "tampered signature", + keyID: "thand-server-current", + payload: payload, + sig: tamperedSig, + errIs: ErrInvalidSignature, + }, + { + name: "mismatched key and signature", + keyID: "thand-server-next", + payload: payload, + sig: sigCurrent, + errIs: ErrInvalidSignature, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := v.Verify(tt.keyID, tt.payload, tt.sig) + if tt.errIs == nil { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if !errors.Is(err, tt.errIs) { + t.Fatalf("expected errors.Is(err, %v)=true, got err=%v", tt.errIs, err) + } + }) + } +}