Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ go 1.24.4

require (
filippo.io/age v1.3.1
github.com/creack/pty v1.1.24
github.com/elazarl/goproxy v1.8.1
github.com/itchyny/gojq v0.12.18
golang.org/x/sys v0.40.0
golang.org/x/sys v0.41.0
golang.org/x/term v0.40.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v1.8.1 h1:/qGpPJGgIPOTZ7IoIQvjavocp//qYSe9LQnIGCgRY5k=
Expand All @@ -24,6 +26,10 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
Expand Down
26 changes: 24 additions & 2 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ func ComputeHMAC(secret []byte, timestamp, tool, cwd string, args []string, nonc
// timestamp + tool + json(args) + cwd + json(env canonical) + nonce.
// Fields are separated by newlines to prevent boundary confusion.
func ComputeHMACWithEnv(secret []byte, timestamp, tool, cwd string, args []string, env map[string]string, nonce string) (string, error) {
return ComputeHMACWithPTY(secret, timestamp, tool, cwd, args, env, nonce, false)
}

// ComputeHMACWithPTY computes an HMAC-SHA256 signature including the PTY flag.
// Message format: timestamp + tool + json(args) + cwd + json(env canonical) + nonce + ptyFlag
// where ptyFlag is "1" if usePTY is true, "0" otherwise.
// Fields are separated by newlines to prevent boundary confusion.
func ComputeHMACWithPTY(secret []byte, timestamp, tool, cwd string, args []string, env map[string]string, nonce string, usePTY bool) (string, error) {
// Serialize args as JSON for consistent encoding
argsJSON, err := json.Marshal(args)
if err != nil {
Expand All @@ -172,8 +180,14 @@ func ComputeHMACWithEnv(secret []byte, timestamp, tool, cwd string, args []strin
return "", fmt.Errorf("failed to marshal env: %w", err)
}

// PTY flag as string for consistent encoding
ptyFlag := "0"
if usePTY {
ptyFlag = "1"
}

// Build the message to sign — fields separated by \n to prevent boundary confusion
message := timestamp + "\n" + tool + "\n" + string(argsJSON) + "\n" + cwd + "\n" + envJSON + "\n" + nonce
message := timestamp + "\n" + tool + "\n" + string(argsJSON) + "\n" + cwd + "\n" + envJSON + "\n" + nonce + "\n" + ptyFlag

// Compute HMAC-SHA256
mac := hmac.New(sha256.New, secret)
Expand All @@ -193,14 +207,22 @@ func VerifyHMAC(secret []byte, timestamp, tool, cwd string, args []string, nonce
}

// VerifyHMACWithEnv verifies the provided HMAC signature with env included.
// For backward compatibility, assumes usePTY=false.
func VerifyHMACWithEnv(secret []byte, timestamp, tool, cwd string, args []string, env map[string]string, nonce, providedHMAC string) error {
return VerifyHMACWithPTY(secret, timestamp, tool, cwd, args, env, nonce, false, providedHMAC)
}

// VerifyHMACWithPTY verifies the provided HMAC signature with PTY flag included.
// It uses constant-time comparison to prevent timing attacks and validates
// that the timestamp is within the allowed freshness window.
func VerifyHMACWithPTY(secret []byte, timestamp, tool, cwd string, args []string, env map[string]string, nonce string, usePTY bool, providedHMAC string) error {
// First validate timestamp freshness
if err := ValidateTimestamp(timestamp); err != nil {
return err
}

// Compute expected HMAC
expectedHMAC, err := ComputeHMACWithEnv(secret, timestamp, tool, cwd, args, env, nonce)
expectedHMAC, err := ComputeHMACWithPTY(secret, timestamp, tool, cwd, args, env, nonce, usePTY)
if err != nil {
return fmt.Errorf("failed to compute expected HMAC: %w", err)
}
Expand Down
79 changes: 79 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -635,3 +635,82 @@ func TestLoadSecret_RejectsSymlink(t *testing.T) {
t.Errorf("LoadSecret() error = %v, want symlink-related error", err)
}
}

func TestComputeHMACWithPTY_DifferentPTYFlag(t *testing.T) {
// PTY flag should change the HMAC signature
secret := []byte("test-secret-key-for-hmac-testing")
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
tool := "test-tool"
cwd := "/home/user"
args := []string{"arg1", "arg2"}
env := map[string]string{"FOO": "bar"}
nonce := "dGVzdC1ub25jZS0xMjM0" // base64("test-nonce-1234")

// Compute HMAC with PTY false
hmacNoPTY, err := ComputeHMACWithPTY(secret, timestamp, tool, cwd, args, env, nonce, false)
if err != nil {
t.Fatalf("ComputeHMACWithPTY(usePTY=false) error = %v", err)
}

// Compute HMAC with PTY true
hmacWithPTY, err := ComputeHMACWithPTY(secret, timestamp, tool, cwd, args, env, nonce, true)
if err != nil {
t.Fatalf("ComputeHMACWithPTY(usePTY=true) error = %v", err)
}

// Signatures must be different
if hmacNoPTY == hmacWithPTY {
t.Error("HMACs should differ based on PTY flag")
}
}

func TestVerifyHMACWithPTY_PTYFlagMismatch(t *testing.T) {
// HMAC computed with usePTY=true should fail verification with usePTY=false
secret := []byte("test-secret-key-for-hmac-testing")
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
tool := "test-tool"
cwd := "/home/user"
args := []string{"arg1"}
nonce := "dGVzdC1ub25jZS0xMjM0"

// Compute with PTY=true
sig, err := ComputeHMACWithPTY(secret, timestamp, tool, cwd, args, nil, nonce, true)
if err != nil {
t.Fatalf("ComputeHMACWithPTY() error = %v", err)
}

// Verify with PTY=true should pass
if err := VerifyHMACWithPTY(secret, timestamp, tool, cwd, args, nil, nonce, true, sig); err != nil {
t.Errorf("VerifyHMACWithPTY(usePTY=true) rejected valid signature: %v", err)
}

// Verify with PTY=false should fail
if err := VerifyHMACWithPTY(secret, timestamp, tool, cwd, args, nil, nonce, false, sig); err == nil {
t.Error("VerifyHMACWithPTY(usePTY=false) should reject signature computed with usePTY=true")
}
}

func TestComputeHMACWithEnv_BackwardCompatibleWithPTYFalse(t *testing.T) {
// ComputeHMACWithEnv should produce same result as ComputeHMACWithPTY(usePTY=false)
secret := []byte("test-secret-key-for-hmac-testing")
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
tool := "test-tool"
cwd := "/home/user"
args := []string{"arg1", "arg2"}
env := map[string]string{"VAR1": "value1"}
nonce := "dGVzdC1ub25jZS0xMjM0"

hmacEnv, err := ComputeHMACWithEnv(secret, timestamp, tool, cwd, args, env, nonce)
if err != nil {
t.Fatalf("ComputeHMACWithEnv() error = %v", err)
}

hmacPTYFalse, err := ComputeHMACWithPTY(secret, timestamp, tool, cwd, args, env, nonce, false)
if err != nil {
t.Fatalf("ComputeHMACWithPTY() error = %v", err)
}

if hmacEnv != hmacPTYFalse {
t.Error("ComputeHMACWithEnv should equal ComputeHMACWithPTY(usePTY=false) for backward compatibility")
}
}
1 change: 1 addition & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ type ToolDef struct {
RedactOutput []ToolRedactRule `yaml:"redact_output,omitempty"`
ConfigFile *ConfigFileDef `yaml:"config_file,omitempty"`
UseProxy bool `yaml:"use_proxy,omitempty"` // Enable HTTP proxy for this tool
UsePTY bool `yaml:"use_pty,omitempty"` // Enable PTY mode for interactive TUI apps
}

// ToolRedactRule defines an output redaction rule for tool stdout/stderr.
Expand Down
60 changes: 60 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,66 @@ func TestValidate_FullValidConfig(t *testing.T) {
}
}

func TestValidate_UsePTY_ValidConfig(t *testing.T) {
cfg := &Config{
Tools: map[string]ToolDef{
"vim": {
Binary: "/usr/bin/vim",
UsePTY: true,
},
"grep": {
Binary: "/usr/bin/grep",
UsePTY: false,
},
},
}
err := cfg.Validate()
if err != nil {
t.Errorf("Validate() unexpected error for PTY config: %v", err)
}

// Verify the flag is preserved
if !cfg.Tools["vim"].UsePTY {
t.Error("UsePTY should be true for vim")
}
if cfg.Tools["grep"].UsePTY {
t.Error("UsePTY should be false for grep")
}
}

func TestLoad_UsePTY_FromYAML(t *testing.T) {
yaml := `
tools:
vim:
binary: /usr/bin/vim
use_pty: true
grep:
binary: /usr/bin/grep
`
tmpFile, err := os.CreateTemp("", "config-pty-*.yaml")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer os.Remove(tmpFile.Name())

if _, err := tmpFile.WriteString(yaml); err != nil {
t.Fatalf("Failed to write config: %v", err)
}
tmpFile.Close()

cfg, err := Load(tmpFile.Name())
if err != nil {
t.Fatalf("Load() error = %v", err)
}

if !cfg.Tools["vim"].UsePTY {
t.Error("UsePTY should be true for vim (from YAML)")
}
if cfg.Tools["grep"].UsePTY {
t.Error("UsePTY should default to false for grep")
}
}

func TestValidate_ConfigFilePathTraversalRejected(t *testing.T) {
cfg := &Config{
Credentials: map[string]CredentialDef{
Expand Down
2 changes: 1 addition & 1 deletion internal/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ func (d *Daemon) handleProxyRequest(conn net.Conn, data []byte, cfg *config.Conf
return
}

if err := auth.VerifyHMACWithEnv(d.secret, req.Timestamp, req.Tool, req.Cwd, req.Args, req.Env, req.Nonce, req.HMAC); err != nil {
if err := auth.VerifyHMACWithPTY(d.secret, req.Timestamp, req.Tool, req.Cwd, req.Args, req.Env, req.Nonce, req.UsePTY, req.HMAC); err != nil {
d.metrics.Inc("auth_fail")
log.Printf("[WARN] deny reason=auth_failed tool=%s err=%v", req.Tool, err)
d.sendProxyError(conn, "authentication failed")
Expand Down
Loading