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 <noreply@anthropic.com>

* 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 <help@stably.ai>

* fix(ssh): isolate GSSAPI system transport

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: s546126 <268420947+s546126@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
s546126 2026-07-15 01:58:46 -07:00 committed by GitHub
parent 9e2c63ec7c
commit 0302ae86b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 597 additions and 11 deletions

View File

@ -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.

View File

@ -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',

View File

@ -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
})

View File

@ -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',

View File

@ -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
}

View File

@ -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', () => {

View File

@ -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<SshTarget, 'gssapiAuthentication'>,
resolved: Pick<SshResolvedConfig, 'gssapiAuthentication'> | 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<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@ -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<typeof createSystemCommandChannel> | null = null
spawnSystemSshCommandMock.mockImplementation(() => {
const channel = new EventEmitter() as ReturnType<typeof createSystemCommandChannel>
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())

View File

@ -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<void> {
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')
}

View File

@ -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(

View File

@ -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)

View File

@ -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,

View File

@ -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({

View File

@ -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 () => {

View File

@ -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)
)
}

View File

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