From 0302ae86b8487da81ec0667b174a7cb7bb0dcdac Mon Sep 17 00:00:00 2001 From: s546126 Date: Wed, 15 Jul 2026 01:58:46 -0700 Subject: [PATCH] feat(ssh): support Kerberos/GSSAPI hosts via the system OpenSSH transport (#7507) * feat(ssh): support Kerberos/GSSAPI hosts via the system OpenSSH transport ssh2 has no gssapi-with-mic support, and adding it would mean forking its protocol layer plus packaging the kerberos native module for three platforms. Instead, route GSSAPI hosts through the existing system-OpenSSH transport, which delegates Kerberos (tickets, SSPI on Windows) to the platform ssh binary. Two tiers, because RHEL-family distros enable GSSAPIAuthentication globally in /etc/ssh/ssh_config and ssh -G therefore reports it for every host: - Targets whose ~/.ssh/config Host block explicitly sets GSSAPIAuthentication yes (imported as target.gssapiAuthentication) try system ssh first, falling through to ssh2 so key auth and credential prompts still work when no ticket is available. - When ssh2 exhausts key/agent auth and the ssh -G-resolved config enables GSSAPI, retry over system ssh before prompting for credentials, so Kerberos-only hosts on distro-default configs connect without a password prompt. Hosts where keys work never leave the ssh2 path. Manual targets flagged for GSSAPI pass -o GSSAPIAuthentication=yes explicitly since they bypass ssh_config. Both tiers work headless (no credential callbacks required). Co-Authored-By: Claude Fable 5 * fix(ssh): harden GSSAPI transport selection (review fixes for PR #7507) Review fixes on top of the Kerberos/GSSAPI feature branch (s546126/kerberos-ssh): - HIGH: reset useSystemSshTransport on the ssh2 fall-through. doSystemSshProbe sets the flag before spawnSystemSshCommand, which throws synchronously when no system ssh binary is on PATH (outside the probe try/catch). The proactive fall-through previously reset only 2 of 3 transport fields, so exec/sftp kept routing through the failed transport - breaking GSSAPI on Windows-with-Git-ssh and headless Linux. - MEDIUM: throw a cancellation error (not the stale ssh2 authError) when a disconnect supersedes the reactive probe mid-flight, and guard connect()'s catch on disposed, so a deliberate disconnect is not overwritten with auth-failed. - MEDIUM: skip the encrypted-key passphrase prompt when the GSSAPI fallback applies, so a Kerberos ticket is tried before prompting; the general prompt still fires if the probe fails. Adds 3 mutation-verified regression tests and hardens two existing tests to assert the probe actually ran. Not connected to any PR remote. Co-authored-by: Orca * fix(ssh): isolate GSSAPI system transport Co-authored-by: Orca --------- Co-authored-by: s546126 <268420947+s546126@users.noreply.github.com> Co-authored-by: Claude Fable 5 Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Jinwoo-H Co-authored-by: Orca --- docs/reference/ssh-gssapi-kerberos.md | 39 +++ src/main/ssh/ssh-config-parser.test.ts | 49 ++++ src/main/ssh/ssh-config-parser.ts | 8 + src/main/ssh/ssh-connection-store.test.ts | 28 +++ src/main/ssh/ssh-connection-store.ts | 1 + src/main/ssh/ssh-connection-utils.test.ts | 44 ++++ src/main/ssh/ssh-connection-utils.ts | 18 ++ src/main/ssh/ssh-connection.test.ts | 230 ++++++++++++++++++ src/main/ssh/ssh-connection.ts | 74 +++++- src/main/ssh/ssh-control-socket.test.ts | 7 + src/main/ssh/ssh-control-socket.ts | 7 +- src/main/ssh/ssh-g-config-resolution.ts | 3 + src/main/ssh/ssh-system-fallback.test.ts | 47 ++++ .../ssh-system-transport.integration.test.ts | 21 +- src/main/ssh/system-ssh-args.ts | 28 ++- src/shared/ssh-types.ts | 4 + 16 files changed, 597 insertions(+), 11 deletions(-) create mode 100644 docs/reference/ssh-gssapi-kerberos.md diff --git a/docs/reference/ssh-gssapi-kerberos.md b/docs/reference/ssh-gssapi-kerberos.md new file mode 100644 index 000000000..e75941516 --- /dev/null +++ b/docs/reference/ssh-gssapi-kerberos.md @@ -0,0 +1,39 @@ +# Kerberos / GSSAPI SSH Authentication + +Orca's ssh2-based SSH stack cannot speak `gssapi-with-mic` — the ssh2 library +has no GSSAPI userauth support, and adding it would mean forking ssh2's +protocol layer plus shipping the `kerberos` native module (MIT krb5 / Heimdal / +Windows SSPI) as a prebuilt Electron dependency on three platforms. Instead, +hosts that need Kerberos ride the existing **system OpenSSH transport** — the +same parallel transport already used for `ProxyCommand`/`ProxyJump` hosts — +which delegates GSSAPI, ticket lookup (`kinit` cache, Windows domain logon), +and credential delegation to the platform's own `ssh` binary on macOS, Linux, +and Windows (Win32-OpenSSH uses SSPI). + +## Transport selection + +Two tiers, deliberately asymmetric because RHEL-family distros ship +`GSSAPIAuthentication yes` in the global `/etc/ssh/ssh_config`, which makes +`ssh -G` report GSSAPI enabled for *every* host: + +1. **Proactive** — a target with `gssapiAuthentication: true` (parsed from an + explicit `GSSAPIAuthentication yes` in the host's `~/.ssh/config` block, or + set on the target directly) tries the system-ssh probe first. If that fails + (e.g. no ticket), the connect falls through to the normal ssh2 key/agent + path where passphrase/password prompts remain available — OpenSSH semantics + allow other auth methods alongside GSSAPI. +2. **Auth-failure fallback** — when ssh2 exhausts key/agent auth and the + `ssh -G`-resolved config enables GSSAPI (`isGssapiSystemSshFallbackCandidate` + in `ssh-connection-utils.ts`), the connection retries over system ssh + *before* prompting the user for credentials. Kerberos-only hosts on + distro-default configs connect this way; hosts where keys work never leave + the ssh2 path. + +Manual (non-ssh-config) targets flagged for GSSAPI get an explicit +`-o GSSAPIAuthentication=yes` in `system-ssh-args.ts`; config-backed targets +inherit the option from their own `Host` block since the system binary re-reads +ssh_config. + +Both tiers work headless (`orca serve`): the system-ssh probe needs no +credential callbacks, and GSSAPI itself is non-interactive once a ticket +exists. diff --git a/src/main/ssh/ssh-config-parser.test.ts b/src/main/ssh/ssh-config-parser.test.ts index f550c833a..a9eda1f02 100644 --- a/src/main/ssh/ssh-config-parser.test.ts +++ b/src/main/ssh/ssh-config-parser.test.ts @@ -163,6 +163,40 @@ Host myserver expect(hosts[0].identitiesOnly).toBe(true) }) + it('parses GSSAPIAuthentication', () => { + const config = ` +Host krb-host + HostName krb.example.com + GSSAPIAuthentication yes + +Host plain-host + HostName plain.example.com + GSSAPIAuthentication no + +Host silent-host + HostName silent.example.com +` + const hosts = parseSshConfig(config) + expect(hosts[0].gssapiAuthentication).toBe(true) + expect(hosts[1].gssapiAuthentication).toBe(false) + expect(hosts[2].gssapiAuthentication).toBeUndefined() + }) + + it('keeps the first GSSAPIAuthentication value like OpenSSH', () => { + const hosts = parseSshConfig(` +Host enabled + GSSAPIAuthentication yes + GSSAPIAuthentication no + +Host disabled + GSSAPIAuthentication no + GSSAPIAuthentication yes +`) + + expect(hosts[0].gssapiAuthentication).toBe(true) + expect(hosts[1].gssapiAuthentication).toBe(false) + }) + it('parses ProxyCommand, ProxyUseFdpass, and ProxyJump', () => { const config = ` Host internal @@ -359,6 +393,12 @@ describe('sshConfigHostsToTargets', () => { expect(targets[0].jumpHost).toBe('bastion.example.com') }) + it('carries through gssapiAuthentication', () => { + const hosts = [{ host: 'krb-host', hostname: 'krb.example.com', gssapiAuthentication: true }] + const targets = sshConfigHostsToTargets(hosts, new Set()) + expect(targets[0].gssapiAuthentication).toBe(true) + }) + it('imports duplicate aliases only once and keeps the first concrete host', () => { const hosts = [ { host: 'dup', hostname: 'first.example.com', user: 'first' }, @@ -508,6 +548,15 @@ describe('parseSshGOutput', () => { expect(result.identitiesOnly).toBe(true) }) + it('parses gssapiauthentication', () => { + const output = 'hostname example.com\ngssapiauthentication yes\nport 22' + const result = parseSshGOutput(output) + expect(result.gssapiAuthentication).toBe(true) + + const offResult = parseSshGOutput('hostname example.com\ngssapiauthentication no\nport 22') + expect(offResult.gssapiAuthentication).toBe(false) + }) + it('parses controlmaster options and filters controlpath none', () => { const output = [ 'hostname example.com', diff --git a/src/main/ssh/ssh-config-parser.ts b/src/main/ssh/ssh-config-parser.ts index 9bd02f49e..92cec9dac 100644 --- a/src/main/ssh/ssh-config-parser.ts +++ b/src/main/ssh/ssh-config-parser.ts @@ -14,6 +14,7 @@ export type SshConfigHost = { identityFile?: string identityAgent?: string identitiesOnly?: boolean + gssapiAuthentication?: boolean proxyCommand?: string proxyUseFdpass?: boolean proxyJump?: string @@ -104,6 +105,12 @@ export function parseSshConfig(content: string): SshConfigHost[] { host.identitiesOnly = value.toLowerCase() === 'yes' } break + case 'gssapiauthentication': + for (const host of current) { + // Why: OpenSSH uses the first obtained value for each parameter. + host.gssapiAuthentication ??= value.toLowerCase() === 'yes' + } + break case 'proxycommand': for (const host of current) { // Why: OpenSSH treats ProxyCommand as a shell snippet and preserves @@ -249,6 +256,7 @@ export function sshConfigHostsToTargets( identityFile: entry.identityFile, identityAgent: entry.identityAgent, identitiesOnly: entry.identitiesOnly, + gssapiAuthentication: entry.gssapiAuthentication, proxyCommand: entry.proxyCommand, jumpHost: entry.proxyJump }) diff --git a/src/main/ssh/ssh-connection-store.test.ts b/src/main/ssh/ssh-connection-store.test.ts index 4bfa7259d..90d92a149 100644 --- a/src/main/ssh/ssh-connection-store.test.ts +++ b/src/main/ssh/ssh-connection-store.test.ts @@ -260,6 +260,34 @@ describe('SshConnectionStore', () => { ) }) + it('refreshes gssapiAuthentication on sync', () => { + mockStore.addSshTarget({ + id: 'ssh-1', + label: 'krb-box', + configHost: 'krb-box', + host: 'krb.example.com', + port: 22, + username: 'dev', + source: 'ssh-config' + }) + loadUserSshConfigMock.mockReturnValue([{ host: 'krb-box' }]) + sshConfigHostsToTargetsMock.mockReturnValue([ + candidate({ + configHost: 'krb-box', + host: 'krb.example.com', + username: 'dev', + gssapiAuthentication: true + }) + ]) + + sshStore.importFromSshConfig() + + expect(mockStore.updateSshTarget).toHaveBeenCalledWith( + 'ssh-1', + expect.objectContaining({ gssapiAuthentication: true }) + ) + }) + it('never overwrites a manual target that owns the alias', () => { mockStore.addSshTarget({ id: 'ssh-m', diff --git a/src/main/ssh/ssh-connection-store.ts b/src/main/ssh/ssh-connection-store.ts index 295e7d57a..408b4522a 100644 --- a/src/main/ssh/ssh-connection-store.ts +++ b/src/main/ssh/ssh-connection-store.ts @@ -184,6 +184,7 @@ export class SshConnectionStore { identityFile: candidate.identityFile, identityAgent: candidate.identityAgent, identitiesOnly: candidate.identitiesOnly, + gssapiAuthentication: candidate.gssapiAuthentication, proxyCommand: candidate.proxyCommand, jumpHost: candidate.jumpHost } diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index 0c578cff7..50f2eb66f 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -31,6 +31,7 @@ vi.mock('fs', () => ({ import { isTransientError, isSystemSshFallbackError, + isGssapiSystemSshFallbackCandidate, isAuthError, isAgentFallbackError, sleep, @@ -166,6 +167,49 @@ describe('isSystemSshFallbackError', () => { }) }) +// ── isGssapiSystemSshFallbackCandidate ─────────────────────────────── + +describe('isGssapiSystemSshFallbackCandidate', () => { + const authErr = new Error('All configured authentication methods failed') + + it('returns true for auth failures when resolved config enables GSSAPI', () => { + expect(isGssapiSystemSshFallbackCandidate(authErr, {}, { gssapiAuthentication: true })).toBe( + true + ) + }) + + it('returns true for passphrase failures so Kerberos SSO runs before prompting', () => { + const passphraseErr = new Error('Encrypted private OpenSSH key detected, but no passphrase') + expect( + isGssapiSystemSshFallbackCandidate(passphraseErr, {}, { gssapiAuthentication: true }) + ).toBe(true) + }) + + it('returns false when the target already tried system ssh proactively', () => { + expect( + isGssapiSystemSshFallbackCandidate( + authErr, + { gssapiAuthentication: true }, + { gssapiAuthentication: true } + ) + ).toBe(false) + }) + + it('returns false without GSSAPI in the resolved config', () => { + expect(isGssapiSystemSshFallbackCandidate(authErr, {}, { gssapiAuthentication: false })).toBe( + false + ) + expect(isGssapiSystemSshFallbackCandidate(authErr, {}, null)).toBe(false) + }) + + it('returns false for network errors so retry semantics stay unchanged', () => { + const netErr = new Error('connect ETIMEDOUT 1.2.3.4:22') + expect(isGssapiSystemSshFallbackCandidate(netErr, {}, { gssapiAuthentication: true })).toBe( + false + ) + }) +}) + // ── isAuthError ────────────────────────────────────────────────────── describe('isAuthError', () => { diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index cc8269c1b..9e496ae0f 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -84,6 +84,24 @@ export function isSystemSshFallbackError(err: Error): boolean { return err.message.includes('EHOSTUNREACH') || err.message.includes('ENETUNREACH') } +// Why: ssh2 has no gssapi-with-mic support. When the effective OpenSSH config +// enables GSSAPIAuthentication (often a distro-wide /etc/ssh default), a +// Kerberos ticket can still authenticate through the system ssh binary after +// key/agent auth fails — but only auth-shaped failures qualify, so network +// errors keep their existing retry semantics. +export function isGssapiSystemSshFallbackCandidate( + err: Error, + target: Pick, + resolved: Pick | null +): boolean { + // Why: targets with an explicit per-host flag already tried system ssh + // proactively during this attempt; probing again cannot succeed. + if (target.gssapiAuthentication === true) { + return false + } + return (isAuthError(err) || isPassphraseError(err)) && resolved?.gssapiAuthentication === true +} + export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index 005d43c9c..d78c2ac15 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -1060,6 +1060,236 @@ describe('SshConnection', () => { expect(conn.usesSystemSshTransport()).toBe(false) }) + it('tries system SSH first for targets that explicitly request GSSAPI authentication', async () => { + const conn = new SshConnection(createTarget({ gssapiAuthentication: true }), createCallbacks()) + + await conn.connect() + await conn.exec('echo after-connect') + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + expect(clientInstances).toHaveLength(0) + expect(spawnSystemSshCommandMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ gssapiAuthentication: true }), + 'echo ORCA-SYSTEM-SSH-OK', + { + gssapiOnly: true, + wrapCommand: false + } + ) + expect(spawnSystemSshCommandMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ gssapiAuthentication: true }), + 'echo after-connect', + { gssapiOnly: true } + ) + }) + + it('falls back to ssh2 when the GSSAPI-first system SSH attempt fails', async () => { + spawnSystemSshCommandMock.mockImplementation(() => + createFailingSystemCommandChannel(255, 'Permission denied (gssapi-with-mic,publickey)') + ) + const conn = new SshConnection(createTarget({ gssapiAuthentication: true }), createCallbacks()) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(false) + expect(clientInstances).toHaveLength(1) + // Why: proves the GSSAPI-first probe actually ran before the ssh2 fallback, + // so the test fails if the proactive block is removed. + expect(spawnSystemSshCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ gssapiAuthentication: true }), + 'echo ORCA-SYSTEM-SSH-OK', + { + gssapiOnly: true, + wrapCommand: false + } + ) + }) + + it('falls back to system SSH after an ssh2 auth failure when resolved config enables GSSAPI', async () => { + connectBehavior = 'error' + connectErrorMessage = 'All configured authentication methods failed' + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true }) + ) + const onCredentialRequest = vi.fn(async () => 'password-123') + const conn = new SshConnection( + createTarget({ configHost: 'krb-host' }), + createCallbacks({ onCredentialRequest }) + ) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + expect(onCredentialRequest).not.toHaveBeenCalled() + }) + + it('connects through the GSSAPI fallback without credential callbacks (headless)', async () => { + connectBehavior = 'error' + connectErrorMessage = 'All configured authentication methods failed' + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true }) + ) + const conn = new SshConnection(createTarget({ configHost: 'krb-host' }), createCallbacks()) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + }) + + it('keeps prompting for credentials when the GSSAPI fallback probe fails', async () => { + // Why: identityAgent 'none' makes resolveAgentSocket return undefined on + // every platform (SSH_AUTH_SOCK='' alone leaves the Windows agent pipe), so + // ssh2's first connect carries any default key directly and the agent + // fallback retry never consumes the second connectSequence entry — + // deterministic on dev machines with both ~/.ssh/id_* and a live agent. + vi.stubEnv('SSH_AUTH_SOCK', '') + connectSequence = [new Error('All configured authentication methods failed'), 'ready'] + spawnSystemSshCommandMock.mockImplementation(() => + createFailingSystemCommandChannel(255, 'Permission denied (gssapi-with-mic,password)') + ) + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ + proxyUseFdpass: false, + gssapiAuthentication: true, + identityAgent: 'none' + }) + ) + const onCredentialRequest = vi.fn(async () => 'password-123') + const conn = new SshConnection( + createTarget({ configHost: 'krb-host' }), + createCallbacks({ onCredentialRequest }) + ) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(false) + // Why: proves the reactive GSSAPI probe actually ran before prompting, so + // the test fails if the reactive fallback block is removed. + expect(spawnSystemSshCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ configHost: 'krb-host' }), + 'echo ORCA-SYSTEM-SSH-OK', + expect.objectContaining({ wrapCommand: false }) + ) + expect(onCredentialRequest).toHaveBeenCalledWith('target-1', 'password', expect.any(String)) + }) + + it('tries the GSSAPI probe before prompting for an encrypted key passphrase', async () => { + // Why: a valid Kerberos ticket should connect silently before the user is + // ever asked for the key passphrase. Agent auth fails, the explicit-key + // retry fails with a passphrase error, and resolved GSSAPI is on — so the + // reactive probe must run before onCredentialRequest. + vi.stubEnv('SSH_AUTH_SOCK', '/tmp/agent.sock') + const tempDir = mkdtempSync(join(tmpdir(), 'orca-ssh-key-')) + const keyPath = join(tempDir, 'id_ed25519') + writeFileSync(keyPath, 'test-key') + connectSequence = [ + new Error('All configured authentication methods failed'), + new Error('Encrypted private OpenSSH key detected, but no passphrase given') + ] + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true }) + ) + const order: string[] = [] + spawnSystemSshCommandMock.mockImplementation(() => { + order.push('probe') + return createSystemCommandChannel() + }) + const onCredentialRequest = vi.fn(async () => { + order.push('prompt') + return 'secret' + }) + + try { + const conn = new SshConnection( + createTarget({ configHost: 'krb-host', identityFile: keyPath }), + createCallbacks({ onCredentialRequest }) + ) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(true) + // Why: the probe must precede any passphrase prompt (which here never runs). + expect(order[0]).toBe('probe') + expect(onCredentialRequest).not.toHaveBeenCalled() + } finally { + rmSync(tempDir, { recursive: true, force: true }) + } + }) + + it('does not try system SSH for auth failures when resolved config leaves GSSAPI off', async () => { + connectBehavior = 'error' + connectErrorMessage = 'All configured authentication methods failed' + vi.mocked(resolveWithSshG).mockResolvedValue(createResolvedConfig({ proxyUseFdpass: false })) + const conn = new SshConnection(createTarget({ configHost: 'plain-host' }), createCallbacks()) + + await expect(conn.connect()).rejects.toThrow('All configured authentication methods failed') + expect(conn.getState().status).toBe('auth-failed') + expect(spawnSystemSshCommandMock).not.toHaveBeenCalled() + }) + + it('clears system SSH transport when the GSSAPI-first probe throws synchronously', async () => { + // Why: no system ssh binary makes spawnSystemSshCommand throw before the + // probe's try/catch, so the ssh2 fall-through must still reset the flag — + // otherwise exec/sftp keep routing through the unusable system transport. + spawnSystemSshCommandMock.mockImplementation(() => { + throw new Error('No system ssh binary found. Install OpenSSH.') + }) + connectSequence = ['ready'] + const conn = new SshConnection(createTarget({ gssapiAuthentication: true }), createCallbacks()) + + await conn.connect() + + expect(conn.getState().status).toBe('connected') + expect(conn.usesSystemSshTransport()).toBe(false) + expect(clientInstances).toHaveLength(1) + }) + + it('keeps disconnected state when a disconnect cancels the reactive GSSAPI probe', async () => { + connectBehavior = 'error' + connectErrorMessage = 'All configured authentication methods failed' + vi.mocked(resolveWithSshG).mockResolvedValue( + createResolvedConfig({ proxyUseFdpass: false, gssapiAuthentication: true }) + ) + // Why: a probe channel that stays open until close() leaves the reactive + // fallback pending, so we can disconnect mid-probe; disconnect() then calls + // close() (bumping the generation first), which settles the probe as a + // cancellation rather than a probe failure. + let pendingChannel: ReturnType | null = null + spawnSystemSshCommandMock.mockImplementation(() => { + const channel = new EventEmitter() as ReturnType + channel.stdin = { end: vi.fn(), write: vi.fn() } + channel.stderr = new EventEmitter() + channel.close = vi.fn(() => channel.emit('close', null)) + pendingChannel = channel + return channel + }) + const onStateChange = vi.fn() + const conn = new SshConnection( + createTarget({ configHost: 'krb-host' }), + createCallbacks({ onStateChange }) + ) + + const connectPromise = conn.connect() + // Wait until the reactive probe has spawned its (never-closing) channel. + await vi.waitFor(() => expect(pendingChannel).not.toBeNull()) + + await conn.disconnect() + await connectPromise.catch(() => {}) + + expect(conn.getState().status).toBe('disconnected') + const statuses = onStateChange.mock.calls.map((call) => call[1].status) + expect(statuses).not.toContain('auth-failed') + expect(statuses).not.toContain('error') + }) + it('passes the detected host platform to system SSH file operations', async () => { vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index af74bdc71..2856d33b2 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -27,6 +27,7 @@ import { isAuthError, isAgentFallbackError, isSystemSshFallbackError, + isGssapiSystemSshFallbackCandidate, isPassphraseError, sleep, buildConnectConfig, @@ -73,6 +74,7 @@ export class SshConnection { private systemOperationAbortController = new AbortController() private systemSshResolvedConfig: SshResolvedConfig | null = null private systemSshControlMasterDisabledForSession = false + private systemSshGssapiOnlyForSession = false private useSystemSshTransport = false private state: SshConnectionState private callbacks: SshConnectionCallbacks @@ -491,6 +493,12 @@ export class SshConnection { } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)) + // Why: a concurrent disconnect() already set 'disconnected'; a cancelled + // attempt's late error must not overwrite it with auth-failed/error. + if (this.disposed) { + throw lastError + } + if (isAuthError(lastError) || isPassphraseError(lastError)) { this.setState('auth-failed', lastError.message) throw lastError @@ -525,8 +533,27 @@ export class SshConnection { await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved) return } + // Why: ssh2 has no gssapi-with-mic support, so hosts that explicitly + // request GSSAPIAuthentication try Kerberos SSO via the system OpenSSH + // binary first. Restrict the probe to GSSAPI so missing tickets fall through + // to Orca's existing key and credential-prompt path. + if (this.target.gssapiAuthentication === true) { + try { + await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved, true) + return + } catch (probeErr) { + if (this.disposed || !this.isCurrentConnectAttempt(connectGeneration)) { + throw probeErr + } + } + } + // Why: a synchronous spawn throw (no system ssh binary) bypasses the probe's + // own catch, so the ssh2 fall-through must clear all system-transport state + // itself — otherwise exec/sftp keep routing through the failed transport. this.systemSshResolvedConfig = null this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false + this.useSystemSshTransport = false const config = buildConnectConfig(this.target, resolved) @@ -566,6 +593,7 @@ export class SshConnection { } catch { this.systemSshResolvedConfig = null this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false this.useSystemSshTransport = false throw err } @@ -604,7 +632,15 @@ export class SshConnection { throw keyErr } authError = keyErr - if (isPassphraseError(authError) && !this.cachedPassphrase) { + // Why: when the effective config enables GSSAPI, let the reactive + // system-ssh probe (below) try a Kerberos ticket before prompting + // for the key passphrase; the general passphrase prompt still runs + // if that probe fails, since passphrasePromptHandled stays false. + if ( + isPassphraseError(authError) && + !this.cachedPassphrase && + !isGssapiSystemSshFallbackCandidate(authError, this.target, resolved) + ) { passphrasePromptHandled = true const detail = this.target.identityFile || resolved?.identityFile?.[0] || '(unknown)' const val = await this.callbacks.onCredentialRequest?.( @@ -624,6 +660,28 @@ export class SshConnection { } } + // Why: a Kerberos ticket may authenticate where keys did not; try the + // system ssh binary before falling back to interactive prompts. + if (isGssapiSystemSshFallbackCandidate(authError, this.target, resolved)) { + this.proxyProcess?.kill() + this.proxyProcess = null + try { + await this.doSystemSshProbeWithControlMasterRetry(connectGeneration, resolved, true) + return + } catch { + this.systemSshResolvedConfig = null + this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false + this.useSystemSshTransport = false + } + // Why: if a disconnect/reconnect superseded this attempt mid-probe, throw + // the cancellation error — not the stale ssh2 authError — so connect() + // does not post auth-failed after the target was deliberately disconnected. + if (this.disposed || !this.isCurrentConnectAttempt(connectGeneration)) { + throw this.createCancelledConnectAttemptError() + } + } + if (!this.callbacks.onCredentialRequest) { this.proxyProcess?.kill() this.proxyProcess = null @@ -762,12 +820,15 @@ export class SshConnection { private async doSystemSshProbeWithControlMasterRetry( connectGeneration: number, - resolved: SshResolvedConfig | null + resolved: SshResolvedConfig | null, + gssapiOnly = false ): Promise { this.systemSshResolvedConfig = cloneResolvedConfig(resolved) this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = gssapiOnly const controlPath = getOrcaControlSocketPath(this.target, { - resolvedConfig: this.systemSshResolvedConfig + resolvedConfig: this.systemSshResolvedConfig, + gssapiOnly: this.systemSshGssapiOnlyForSession }) try { await this.doSystemSshProbe(connectGeneration) @@ -937,6 +998,9 @@ export class SshConnection { if (this.systemSshControlMasterDisabledForSession) { options.disableControlMaster = true } + if (this.systemSshGssapiOnlyForSession) { + options.gssapiOnly = true + } return options } @@ -1117,6 +1181,7 @@ export class SshConnection { this.systemSsh = null this.systemSshResolvedConfig = null this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false this.useSystemSshTransport = false } @@ -1129,6 +1194,7 @@ export class SshConnection { this.systemSsh = null this.systemSshResolvedConfig = null this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false this.useSystemSshTransport = false this.setState('connecting') try { @@ -1170,6 +1236,7 @@ export class SshConnection { this.useSystemSshTransport = false this.systemSshResolvedConfig = null this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false this.setState('error', err instanceof Error ? err.message : String(err)) throw err } @@ -1198,6 +1265,7 @@ export class SshConnection { this.systemSsh = null this.systemSshResolvedConfig = null this.systemSshControlMasterDisabledForSession = false + this.systemSshGssapiOnlyForSession = false this.useSystemSshTransport = false this.setState('disconnected') } diff --git a/src/main/ssh/ssh-control-socket.test.ts b/src/main/ssh/ssh-control-socket.test.ts index 98ae45f0d..c08bfc739 100644 --- a/src/main/ssh/ssh-control-socket.test.ts +++ b/src/main/ssh/ssh-control-socket.test.ts @@ -84,6 +84,13 @@ describe.skipIf(process.platform === 'win32')('getControlSocketPath', () => { }) }) + it('uses a separate socket for GSSAPI-only authentication', () => { + const ordinary = getControlSocketPath(createTarget(), createResolved()) + const gssapiOnly = getControlSocketPath(createTarget(), createResolved(), true) + + expect(gssapiOnly).not.toBe(ordinary) + }) + it('changes the path when a config-backed target resolves to a different host', () => { const before = getControlSocketPath(createTarget({ host: '10.0.0.5' }), createResolved()) const after = getControlSocketPath( diff --git a/src/main/ssh/ssh-control-socket.ts b/src/main/ssh/ssh-control-socket.ts index 5cbd0fdec..73622ba50 100644 --- a/src/main/ssh/ssh-control-socket.ts +++ b/src/main/ssh/ssh-control-socket.ts @@ -28,7 +28,8 @@ const CONTROL_SOCKET_PATH_MAX_LENGTH = UNIX_SOCKET_PATH_LIMIT - OPENSSH_CONTROL_ export function getControlSocketPath( target: SshTarget, - resolvedConfig?: SystemSshResolvedConfig | null + resolvedConfig?: SystemSshResolvedConfig | null, + gssapiOnly = false ): string | null { if (process.platform === 'win32') { return null @@ -58,7 +59,9 @@ export function getControlSocketPath( identityAgent: target.identityAgent || '', identitiesOnly: target.identitiesOnly || false }, - resolved: normalizeResolvedConfig(resolvedConfig) + resolved: normalizeResolvedConfig(resolvedConfig), + // Why: a Kerberos-only session must not reuse a master authenticated by a key. + gssapiOnly }) const hash = createHash('sha256').update(key).digest('hex').slice(0, 16) const socketPath = pathJoin(dir, hash) diff --git a/src/main/ssh/ssh-g-config-resolution.ts b/src/main/ssh/ssh-g-config-resolution.ts index c004701e9..29c6d5eaa 100644 --- a/src/main/ssh/ssh-g-config-resolution.ts +++ b/src/main/ssh/ssh-g-config-resolution.ts @@ -9,6 +9,8 @@ export type SshResolvedConfig = { identityAgent?: string identitiesOnly: boolean forwardAgent: boolean + /** Effective GSSAPIAuthentication, including distro-wide /etc/ssh defaults. */ + gssapiAuthentication?: boolean proxyCommand?: string proxyUseFdpass: boolean proxyJump?: string @@ -106,6 +108,7 @@ function buildSshResolvedConfig( identityAgent, identitiesOnly: map.get('identitiesonly') === 'yes', forwardAgent: map.get('forwardagent') === 'yes', + gssapiAuthentication: map.get('gssapiauthentication') === 'yes', proxyCommand, proxyUseFdpass: map.get('proxyusefdpass') === 'yes', proxyJump, diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index 324a0ed6e..36698e92b 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -291,6 +291,53 @@ describe('spawnSystemSsh', () => { expect(args).toContain('deploy@127.0.0.1') }) + it('requests GSSAPI authentication explicitly for manual targets', () => { + const args = buildSshArgs( + createTarget({ source: 'manual', configHost: 'krb.example.com', gssapiAuthentication: true }) + ) + + expect(args).toContain('GSSAPIAuthentication=yes') + }) + + it('restricts Kerberos probes to non-interactive GSSAPI authentication', () => { + spawnSystemSshCommand( + createTarget({ + configHost: 'krb-host; touch /tmp/not-run', + source: 'ssh-config', + gssapiAuthentication: true + }), + 'echo ready', + { gssapiOnly: true, wrapCommand: false } + ) + + const args = spawnMock.mock.calls[0][1] as string[] + expect(args).toEqual( + expect.arrayContaining([ + '-o', + 'BatchMode=yes', + '-o', + 'GSSAPIAuthentication=yes', + '-o', + 'PreferredAuthentications=gssapi-with-mic' + ]) + ) + expect(args).not.toContain('BatchMode=no') + const standaloneControlIdx = args.indexOf('-S') + expect(standaloneControlIdx).toBeGreaterThan(-1) + expect(args[standaloneControlIdx + 1]).toBe('none') + expect(args.at(-2)).toBe('deploy@krb-host; touch /tmp/not-run') + expect(args.at(-1)).toBe('echo ready') + }) + + it('leaves GSSAPI to the Host block for ssh-config targets', () => { + const args = buildSshArgs( + createTarget({ configHost: 'krb-host', source: 'ssh-config', gssapiAuthentication: true }) + ) + + expect(args).not.toContain('GSSAPIAuthentication=yes') + expect(args).toContain('deploy@krb-host') + }) + it('does not inject Orca ControlMaster flags when ssh config already owns muxing', () => { const args = buildSshArgs(createTarget({ configHost: 'workbox', source: 'ssh-config' }), { resolvedConfig: createResolvedConfig({ diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index ffe5bf9c8..b949dc411 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -32,7 +32,7 @@ function writeFakeSsh(dir: string): string { `#!/bin/sh while [ "$#" -gt 0 ]; do case "$1" in - -o|-p|-i|-J) shift 2 ;; + -o|-p|-i|-J|-S) shift 2 ;; -T) shift ;; --) shift; break ;; -*) shift ;; @@ -221,6 +221,25 @@ describe('system SSH transport integration', () => { 20_000 ) + it.skipIf(process.platform === 'win32')( + 'connects GSSAPI-flagged targets through system ssh without the force override', + async () => { + delete process.env.ORCA_SSH_FORCE_SYSTEM_TRANSPORT + const conn = new SshConnection( + { ...makeTarget(), gssapiAuthentication: true }, + { onStateChange: vi.fn() } + ) + await conn.connect() + try { + expect(conn.usesSystemSshTransport()).toBe(true) + expect(conn.getState().status).toBe('connected') + } finally { + await conn.disconnect() + } + }, + 20_000 + ) + it.skipIf(process.platform === 'win32')( 'uploads a directory through the system ssh stdin/stdout path', async () => { diff --git a/src/main/ssh/system-ssh-args.ts b/src/main/ssh/system-ssh-args.ts index 8e812a0b6..810b77b12 100644 --- a/src/main/ssh/system-ssh-args.ts +++ b/src/main/ssh/system-ssh-args.ts @@ -5,21 +5,30 @@ export type SystemSshBuildArgsOptions = { resolvedConfig?: SystemSshResolvedConfig | null disableControlMaster?: boolean suppressOrcaControlMaster?: boolean + gssapiOnly?: boolean } export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOptions): string[] { const args: string[] = [] - args.push('-o', 'BatchMode=no') + args.push('-o', options?.gssapiOnly ? 'BatchMode=yes' : 'BatchMode=no') + if (options?.gssapiOnly) { + // Why: the probe must neither authenticate with a key nor open an OpenSSH + // credential prompt; failure belongs to Orca's existing ssh2 prompt path. + args.push('-o', 'GSSAPIAuthentication=yes') + args.push('-o', 'PreferredAuthentications=gssapi-with-mic') + } // Forward stdin/stdout for relay communication args.push('-T') // Why: ControlMaster multiplexes all SSH exec commands over a single connection, // eliminating the ~9s handshake overhead per command. Without this, each // spawnSystemSshCommand call opens a new TCP connection. - const forceDisableControlMaster = - options?.disableControlMaster === true || target.systemSshConnectionReuse === false const controlPath = getOrcaControlSocketPath(target, options) + const forceDisableControlMaster = + options?.disableControlMaster === true || + target.systemSshConnectionReuse === false || + (options?.gssapiOnly === true && controlPath === null) if (forceDisableControlMaster) { // Why: muxed OpenSSH forwards remain registered on the master after the // client exits. Also honors the per-target compatibility opt-out even if @@ -53,6 +62,12 @@ export function buildSshArgs(target: SshTarget, options?: SystemSshBuildArgsOpti args.push('-o', 'IdentitiesOnly=yes') } + if (!useConfigHost && target.gssapiAuthentication && !options?.gssapiOnly) { + // Why: manual targets bypass ssh_config, so Kerberos auth must be + // requested explicitly; config-backed hosts inherit it from their entry. + args.push('-o', 'GSSAPIAuthentication=yes') + } + if (!useConfigHost && target.jumpHost) { args.push('-J', target.jumpHost) } @@ -75,7 +90,7 @@ export function getOrcaControlSocketPath( if (shouldDisableOrcaControlMaster(target, options)) { return null } - return getControlSocketPath(target, options?.resolvedConfig) + return getControlSocketPath(target, options?.resolvedConfig, options?.gssapiOnly === true) } export function getSystemSshBuildArgsFromOperationOptions( @@ -91,6 +106,9 @@ export function getSystemSshBuildArgsFromOperationOptions( if (options?.suppressOrcaControlMaster === true) { buildArgsOptions.suppressOrcaControlMaster = true } + if (options?.gssapiOnly === true) { + buildArgsOptions.gssapiOnly = true + } return Object.keys(buildArgsOptions).length === 0 ? undefined : buildArgsOptions } @@ -107,7 +125,7 @@ function shouldDisableOrcaControlMaster( options?.suppressOrcaControlMaster === true || target.systemSshConnectionReuse === false || unresolvedConfigBackedTarget || - hasUserConfiguredControlMaster(options?.resolvedConfig) + (hasUserConfiguredControlMaster(options?.resolvedConfig) && options?.gssapiOnly !== true) ) } diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 5794cb522..112722b28 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -24,6 +24,10 @@ export type SshTarget = { identityAgent?: string /** Whether OpenSSH IdentitiesOnly should limit public-key auth attempts. */ identitiesOnly?: boolean + /** Whether the host's SSH config explicitly requests GSSAPIAuthentication + * (Kerberos). ssh2 has no gssapi-with-mic support, so these targets try the + * system OpenSSH transport first. */ + gssapiAuthentication?: boolean /** ProxyCommand from SSH config, if any. */ proxyCommand?: string /** Jump host (ProxyJump), if any. */