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. */