From 5a998aefb9c3017d01435f9f7b71854657942de6 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Thu, 16 Apr 2026 00:00:00 +0100 Subject: [PATCH] Phase 1: Increase Argon2id to 128 MiB and add SSH agent forwarding Argon2id: - Increase memory parameter from 64 MiB to 128 MiB for stronger KDF SSH Agent: - Add agent.go with connectAgent(), agentAuthMethod(), requestAgentForwarding() - Integrate agent auth into buildAuthMethods() (tried first when enabled) - Set up agent forwarding after PTY allocation when agentForwarding is true - Track and clean up agent socket connection on session close - Add AgentAuth and AgentForwarding fields to SSHConfig model - Add SSH Agent section to profile editor with toggle controls --- frontend/src/js/profiles.js | 12 +++++ internal/models/models.go | 2 + internal/secrets/secrets.go | 2 +- internal/sshclient/agent.go | 80 +++++++++++++++++++++++++++++++++ internal/sshclient/sshclient.go | 29 ++++++++++++ 5 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 internal/sshclient/agent.go diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js index 4bc153b..8a86983 100644 --- a/frontend/src/js/profiles.js +++ b/frontend/src/js/profiles.js @@ -390,6 +390,16 @@ class ProfileEditor { }), ])); + // SSH Agent + sections.push(this._renderSection('SSH Agent', [ + this._field('toggle', 'ssh.agentAuth', 'Use SSH Agent for Authentication', ssh.agentAuth, { + help: 'Authenticate using keys from the local SSH agent (ssh-agent)', + }), + this._field('toggle', 'ssh.agentForwarding', 'Forward Agent to Remote', ssh.agentForwarding, { + help: 'Allow the remote host to use your local SSH agent keys (equivalent to ssh -A)', + }), + ], true)); + // Advanced sections.push(this._renderSection('Advanced', [ this._field('text', 'ssh.terminalType', 'Terminal Type', ssh.terminalType, { placeholder: 'xterm-256color' }), @@ -896,6 +906,8 @@ class ProfileEditor { p.ssh.keepaliveInterval = val('ssh.keepaliveInterval') ?? 30; p.ssh.keepaliveCountMax = val('ssh.keepaliveCountMax') ?? 3; p.ssh.compression = val('ssh.compression') || false; + p.ssh.agentAuth = val('ssh.agentAuth') || false; + p.ssh.agentForwarding = val('ssh.agentForwarding') || false; p.ssh.startupCommand = val('ssh.startupCommand') || ''; const authStr = val('ssh.authMethods'); diff --git a/internal/models/models.go b/internal/models/models.go index c0f5ca7..0021a3b 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -93,6 +93,8 @@ type SSHConfig struct { KeepaliveInterval int `json:"keepaliveInterval"` KeepaliveCountMax int `json:"keepaliveCountMax"` Compression bool `json:"compression"` + AgentAuth bool `json:"agentAuth"` // use SSH agent for authentication + AgentForwarding bool `json:"agentForwarding"` // forward agent to remote host JumpHosts []JumpHost `json:"jumpHosts"` PortForwards []PortForward `json:"portForwards"` StartupCommand string `json:"startupCommand"` diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go index 92f8197..c722fb7 100644 --- a/internal/secrets/secrets.go +++ b/internal/secrets/secrets.go @@ -23,7 +23,7 @@ const ( // Argon2id key derivation parameters. argonTime = 3 - argonMemory = 64 * 1024 + argonMemory = 128 * 1024 // 128 MiB — deliberately strong for a desktop app argonThreads = 4 argonKeyLen = 32 diff --git a/internal/sshclient/agent.go b/internal/sshclient/agent.go new file mode 100644 index 0000000..8de7d5c --- /dev/null +++ b/internal/sshclient/agent.go @@ -0,0 +1,80 @@ +// Package sshclient — SSH agent integration. +// +// This file provides SSH agent authentication and agent forwarding. +// It connects to the local SSH agent via the SSH_AUTH_SOCK environment +// variable and can both use agent keys for authentication and forward +// the agent channel to the remote host (allowing the remote session +// to authenticate further hops using local keys). +package sshclient + +import ( + "fmt" + "net" + "os" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +// connectAgent connects to the local SSH agent via SSH_AUTH_SOCK. +// Returns the agent and a cleanup function, or an error if unavailable. +func connectAgent() (agent.Agent, func(), error) { + sock := os.Getenv("SSH_AUTH_SOCK") + if sock == "" { + return nil, nil, fmt.Errorf("SSH_AUTH_SOCK not set — SSH agent unavailable") + } + + conn, err := net.Dial("unix", sock) + if err != nil { + return nil, nil, fmt.Errorf("unable to connect to SSH agent at %s: %w", sock, err) + } + + ag := agent.NewClient(conn) + cleanup := func() { conn.Close() } + return ag, cleanup, nil +} + +// agentAuthMethod returns an ssh.AuthMethod that delegates authentication +// to the running SSH agent. Returns nil if the agent is unavailable. +func agentAuthMethod() (ssh.AuthMethod, func(), error) { + ag, cleanup, err := connectAgent() + if err != nil { + return nil, nil, err + } + + signers, err := ag.Signers() + if err != nil { + cleanup() + return nil, nil, fmt.Errorf("SSH agent: failed to list keys: %w", err) + } + + if len(signers) == 0 { + cleanup() + return nil, nil, fmt.Errorf("SSH agent: no keys loaded") + } + + return ssh.PublicKeysCallback(ag.Signers), cleanup, nil +} + +// requestAgentForwarding sets up agent forwarding on the SSH session so that +// the remote host can use local agent keys for further authentication. +// This is equivalent to `ssh -A`. +func requestAgentForwarding(sshClient *ssh.Client, session *ssh.Session) error { + if err := agent.RequestAgentForwarding(session); err != nil { + return fmt.Errorf("requesting agent forwarding: %w", err) + } + + ag, cleanup, err := connectAgent() + if err != nil { + return err + } + + // ForwardToAgent runs in a background goroutine and serves agent + // requests from the remote side. It stops when the SSH connection closes. + go func() { + defer cleanup() + agent.ForwardToAgent(sshClient, ag) + }() + + return nil +} diff --git a/internal/sshclient/sshclient.go b/internal/sshclient/sshclient.go index be9efaf..34e1acf 100644 --- a/internal/sshclient/sshclient.go +++ b/internal/sshclient/sshclient.go @@ -31,6 +31,7 @@ type Client struct { ctx context.Context cancel context.CancelFunc forwards *ForwardManager // port forwarding tunnels + agentCleanup func() // closes agent socket connection } // NewClient creates a new SSH client bound to the given profile. @@ -151,6 +152,16 @@ func (c *Client) connectInternal( return fmt.Errorf("PTY request failed: %w", err) } + // Set up SSH agent forwarding if requested (must happen before shell start). + if sc.AgentForwarding { + if fwdErr := requestAgentForwarding(sshConn, session); fwdErr != nil { + // Non-fatal — log but don't abort the connection. + if c.onError != nil { + c.onError(fmt.Errorf("agent forwarding: %w", fwdErr)) + } + } + } + stdinPipe, err := session.StdinPipe() if err != nil { session.Close() @@ -245,6 +256,7 @@ func (c *Client) Close() error { session := c.session sshClient := c.sshClient fwdMgr := c.forwards + agCleanup := c.agentCleanup onClose := c.onClose c.mu.Unlock() @@ -255,6 +267,11 @@ func (c *Client) Close() error { fwdMgr.StopAll() } + // Close the SSH agent socket connection. + if agCleanup != nil { + agCleanup() + } + var firstErr error if session != nil { if err := session.Close(); err != nil && firstErr == nil { @@ -292,6 +309,18 @@ func (c *Client) Forwards() *ForwardManager { func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (string, error), getPassphrase func() (string, error)) ([]ssh.AuthMethod, error) { var methods []ssh.AuthMethod + // If agent auth is enabled, try to use the local SSH agent first. + if sc.AgentAuth { + am, cleanup, err := agentAuthMethod() + if err == nil { + methods = append(methods, am) + c.mu.Lock() + c.agentCleanup = cleanup + c.mu.Unlock() + } + // Non-fatal — fall through to other auth methods if agent unavailable. + } + authList := sc.AuthMethods if len(authList) == 0 { authList = []string{"publickey", "password", "keyboard-interactive"}