From d79861ff7cf8927ae0f63b7b814f091da5851c7c Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Mon, 27 Jul 2026 10:49:26 +0200 Subject: [PATCH] elevate: SSH agent proxy with approval gating Proxies SSH agent requests to the real agent, intercepting Sign/SignWithFlags for approval via the same broker flow (notification + persist). Key fingerprints can be persisted to auto-approve future sign requests. --- go.mod | 5 + go.sum | 4 + pkg/elevate/sshagent.go | 211 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 pkg/elevate/sshagent.go diff --git a/go.mod b/go.mod index 0737380..dee2ccc 100644 --- a/go.mod +++ b/go.mod @@ -7,3 +7,8 @@ require gopkg.in/yaml.v3 v3.0.1 require github.com/aymanbagabas/go-udiff v0.4.1 require github.com/simonfxr/pubsub v0.0.5 + +require ( + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/sys v0.47.0 // indirect +) diff --git a/go.sum b/go.sum index e289754..7c6dd75 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,10 @@ github.com/simonfxr/pubsub v0.0.5 h1:DJfvFoglqGvwJriIOC5NI5um34n2YX8KAU5+7jv768w github.com/simonfxr/pubsub v0.0.5/go.mod h1:bQ+B2NEEHZ08VY/0xVFGaGL1KMJ7cl3yLaXe8xvyC24= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/elevate/sshagent.go b/pkg/elevate/sshagent.go new file mode 100644 index 0000000..01bc36b --- /dev/null +++ b/pkg/elevate/sshagent.go @@ -0,0 +1,211 @@ +package elevate + +import ( + "fmt" + "net" + "os" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +// SSHAgentProxy proxies SSH agent requests to the real agent, +// intercepting sign requests for approval via the elevation broker. +type SSHAgentProxy struct { + upstreamPath string + broker *Broker + listener net.Listener + logf func(string, ...any) +} + +// SSHAgentConfig configures the SSH agent proxy. +type SSHAgentConfig struct { + ListenPath string // path for proxy socket + UpstreamPath string // real SSH_AUTH_SOCK + Broker *Broker + Logf func(string, ...any) +} + +// NewSSHAgentProxy creates and starts the SSH agent proxy. +func NewSSHAgentProxy(cfg SSHAgentConfig) (*SSHAgentProxy, error) { + if cfg.Logf == nil { + cfg.Logf = func(string, ...any) {} + } + if cfg.UpstreamPath == "" { + cfg.UpstreamPath = os.Getenv("SSH_AUTH_SOCK") + } + if cfg.UpstreamPath == "" { + return nil, fmt.Errorf("ssh-agent-proxy: no upstream SSH_AUTH_SOCK") + } + + // Ensure directory exists + dir := socketDir(cfg.ListenPath) + os.MkdirAll(dir, 0700) //nolint:errcheck + os.Remove(cfg.ListenPath) //nolint:errcheck + + ln, err := net.Listen("unix", cfg.ListenPath) + if err != nil { + return nil, fmt.Errorf("ssh-agent-proxy: listen %s: %w", cfg.ListenPath, err) + } + os.Chmod(cfg.ListenPath, 0600) //nolint:errcheck + + p := &SSHAgentProxy{ + upstreamPath: cfg.UpstreamPath, + broker: cfg.Broker, + listener: ln, + logf: cfg.Logf, + } + + go p.acceptLoop() + cfg.Logf("ssh-agent-proxy: listening on %s (upstream: %s)", cfg.ListenPath, cfg.UpstreamPath) + return p, nil +} + +// Close stops the proxy. +func (p *SSHAgentProxy) Close() { + p.listener.Close() +} + +func (p *SSHAgentProxy) acceptLoop() { + for { + conn, err := p.listener.Accept() + if err != nil { + return + } + go p.handleConn(conn) + } +} + +func (p *SSHAgentProxy) handleConn(conn net.Conn) { + defer conn.Close() + + // Connect to upstream agent + upstream, err := net.Dial("unix", p.upstreamPath) + if err != nil { + p.logf("ssh-agent-proxy: connect upstream: %v", err) + return + } + defer upstream.Close() + + upstreamAgent := agent.NewClient(upstream) + proxy := &approvalAgent{ + upstream: upstreamAgent, + broker: p.broker, + logf: p.logf, + } + + // Serve the agent protocol on the client connection + agent.ServeAgent(proxy, conn) //nolint:errcheck +} + +// approvalAgent wraps an upstream agent, requiring approval for sign requests. +type approvalAgent struct { + upstream agent.ExtendedAgent + broker *Broker + logf func(string, ...any) +} + +func (a *approvalAgent) List() ([]*agent.Key, error) { + return a.upstream.List() +} + +func (a *approvalAgent) Sign(key ssh.PublicKey, data []byte) (*ssh.Signature, error) { + return a.signWithApproval(key, data, 0) +} + +func (a *approvalAgent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) { + return a.signWithApproval(key, data, flags) +} + +func (a *approvalAgent) signWithApproval(key ssh.PublicKey, data []byte, flags agent.SignatureFlags) (*ssh.Signature, error) { + fp := ssh.FingerprintSHA256(key) + + // Check policy + effective := a.broker.SessionPolicy("") + if effective.Matches("", fp) { + a.logf("ssh-agent-proxy: auto-approved sign for %s", fp) + if flags != 0 { + return a.upstream.SignWithFlags(key, data, flags) + } + return a.upstream.Sign(key, data) + } + + // Create pending request for approval + req := &Request{ + ID: nextRequestID(), + Cmd: fmt.Sprintf("ssh-sign: %s", fp), + Cwd: "", + SessionID: "", + CreatedAt: timeNow(), + resolved: make(chan Resolution, 1), + } + + a.broker.mu.Lock() + a.broker.pending[req.ID] = req + a.broker.mu.Unlock() + + a.broker.notify(req) + a.logf("ssh-agent-proxy: pending sign approval id=%s fingerprint=%s", req.ID, fp) + + // Wait for resolution or timeout + var res Resolution + select { + case res = <-req.Resolved(): + case <-timeAfter(RequestTTL): + a.broker.mu.Lock() + delete(a.broker.pending, req.ID) + a.broker.mu.Unlock() + res = ResolveTimeout + } + + switch res { + case ResolveApprove: + a.logf("ssh-agent-proxy: approved sign id=%s", req.ID) + case ResolvePersist: + a.logf("ssh-agent-proxy: persisted sign id=%s fingerprint=%s", req.ID, fp) + a.broker.policy.AddGlobal(Rule{SSH: fp}) //nolint:errcheck + default: + a.logf("ssh-agent-proxy: denied sign id=%s reason=%s", req.ID, res) + return nil, fmt.Errorf("sign request denied") + } + + if flags != 0 { + return a.upstream.SignWithFlags(key, data, flags) + } + return a.upstream.Sign(key, data) +} + +func (a *approvalAgent) Add(key agent.AddedKey) error { + return fmt.Errorf("add not allowed via proxy") +} + +func (a *approvalAgent) Remove(key ssh.PublicKey) error { + return fmt.Errorf("remove not allowed via proxy") +} + +func (a *approvalAgent) RemoveAll() error { + return fmt.Errorf("remove all not allowed via proxy") +} + +func (a *approvalAgent) Lock(passphrase []byte) error { + return fmt.Errorf("lock not allowed via proxy") +} + +func (a *approvalAgent) Unlock(passphrase []byte) error { + return fmt.Errorf("unlock not allowed via proxy") +} + +func (a *approvalAgent) Signers() ([]ssh.Signer, error) { + return nil, fmt.Errorf("signers not supported via proxy") +} + +func (a *approvalAgent) Extension(extensionType string, contents []byte) ([]byte, error) { + return nil, agent.ErrExtensionUnsupported +} + +// For testing: allow mocking time functions +var ( + timeNow = func() time.Time { return time.Now() } + timeAfter = func(d time.Duration) <-chan time.Time { return time.After(d) } +)