Files
orca/src/main/ssh/ssh-multi-key-authentication.test.ts
T
Neil 278f9ee876 fix(ssh): answer every MFA stage, stop dialling an unclaimed alias, and say where a clone failed (#17946)
* fix(ssh): answer every MFA stage, not just the first

ssh2 walks one flat auth-method list exactly once, so keyboard-interactive
could only ever be offered a single time. A host running
`AuthenticationMethods keyboard-interactive,keyboard-interactive` (or any
ladder ending in a second challenge) partial-succeeds the first stage and
then finds the list exhausted, which the user sees as "All configured
authentication methods failed" — the reports in #8622 and #16820.

Orca's own auth handler now runs for every target instead of only multi-key
ones, and rebuilds its queue on each SSH_MSG_USERAUTH_FAILURE that carries
partial success, narrowed to the methods the host still offers. Narrowing
also stops keys being re-offered after the host has moved past publickey,
which is what exhausts MaxAuthTries before the challenge is ever shown.

Covered by a real ssh2 server fixture that stages partial success.

* fix(git): say where a failing clone ran and why nothing could prompt

Clones go through nonInteractiveGitEnv, so `ssh` runs with BatchMode=yes and an
emptied SSH_ASKPASS. On a remote or paired-runtime clone that produces
`fatal: Could not read from remote repository.` while the same `git clone`
typed by hand on that box succeeds — the divergence in #14533. Nothing in the
message said the clone ran on the other machine, under its keys, with the
prompt deliberately disabled.

getGitCloneFailureMessage now appends that fact, and names the two recognisable
shapes: a publickey refusal (load the key into an agent there) and a host-key
failure (record the key in that machine's known_hosts). Unrecognised SSH
failures still get the where-it-ran note; non-SSH failures are untouched.

One builder, so the SSH-target relay path and the runtime path both get it.

* fix(ssh): stop dialling a bare alias no ssh_config block claims

A wildcard `Host *` block supplies ProxyCommand/ProxyJump for every alias, so
shouldUseSystemSshTransport picks the system transport for an alias whose own
Host block was renamed or deleted, and buildSshArgs then dials that alias
verbatim: no -l, no -p, no Hostname. Orca connects as the wildcard's user to
the wildcard's host and discards the endpoint it stored (#11746).

The signal #11746 assumed (hostBlockMatch, from the still-open #11707) does not
exist, and `ssh -G` cannot supply it — it prints the merged config and answers
for unknown aliases too. The config file is the only source of truth, so:

- parseSshConfigAliasClaims retains raw Host patterns and flags Match blocks,
  which parseSshConfig discards because it mints importable targets.
- sshConfigMayClaimAlias is sound in the negative direction only: an unreadable
  file, any Match block, or any non-catch-all pattern that might match all
  answer "claimed", so absence of evidence is never read as evidence of
  absence. Only a proven-unclaimed alias licenses an override.
- buildSshArgs then states Hostname/Port/User, and only those: the wildcard is
  still the route, and -o Hostname does not change block selection, so the
  proxy keeps applying and %h expands to the host we mean.

The verdict is injected rather than read inside buildSshArgs, so an arg builder
does not answer differently per machine. Default is today's behaviour.

Scoped to the system-SSH transport and the connection's own command/transport
path. Port-forward processes and the ssh2 transport (#11707) are unchanged.

* fix(ssh): read a negated Host group as uncertainty, and gate clone SSH guidance

`Host * !prod` applies to every alias but `prod`, yet skipping both the catch-all
and the `!` pattern answered "unclaimed" for `stage` — which licences overriding
Hostname/Port/User against a block the user wrote. Any negation now makes the
whole group uncertain; the function is only sound in the negative direction.

Also require an ssh(1) diagnostic beside "could not read from remote repository"
before appending the SSH clone note: git prints that same line for the HTTP
remote helper, where advice about keys and agents is simply wrong.

* fix(i18n): restore the activity-options key the rebase dropped

* fix(i18n): union en.json with main so the rebase cannot drop keys
2026-09-02 15:14:53 -07:00

275 lines
9.2 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
utils,
type AnyAuthMethod,
type AuthenticationType,
type AuthHandlerMiddleware,
type ConnectConfig,
type ParsedKey
} from 'ssh2'
vi.mock('os', () => ({
homedir: () => '/home/testuser',
tmpdir: () => '/tmp'
}))
const mockExistsSync = vi.fn().mockReturnValue(false)
const mockReadFileSync = vi.fn()
vi.mock('fs', () => ({
existsSync: (...args: unknown[]) => mockExistsSync(...args),
readFileSync: (...args: unknown[]) => mockReadFileSync(...args)
}))
import { buildConnectConfig } from './ssh-connection-utils'
import { getPassphrasePrivateKeyPath } from './ssh-private-key-authentication'
import { buildSshArgs } from './system-ssh-args'
import type { SshTarget } from '../../shared/ssh-types'
import type { SshResolvedConfig } from './ssh-config-parser'
function makeTarget(overrides: Partial<SshTarget> = {}): SshTarget {
return {
id: 'target-1',
label: 'workbox',
source: 'ssh-config',
configHost: 'workbox',
host: 'stale.example.com',
port: 22,
username: 'stale-user',
identityFile: '/keys/stale-imported',
...overrides
}
}
function makeResolved(overrides: Partial<SshResolvedConfig> = {}): SshResolvedConfig {
return {
hostname: 'current.example.com',
port: 2222,
user: 'current-user',
identityFile: ['/keys/unauthorized-first', '/keys/authorized-second'],
identitiesOnly: true,
forwardAgent: false,
proxyUseFdpass: false,
controlMaster: 'no',
controlPersist: 'no',
userKnownHostsFiles: [],
globalKnownHostsFiles: [],
strictHostKeyChecking: 'ask',
hashKnownHosts: false,
updateHostKeys: 'no',
...overrides
}
}
function nextAuth(
config: ConnectConfig,
firstAttempt: boolean
): AuthenticationType | AnyAuthMethod | false {
let result: AuthenticationType | AnyAuthMethod | false | undefined
const handler = config.authHandler as AuthHandlerMiddleware
handler(
(firstAttempt ? null : ['publickey']) as unknown as AuthenticationType[],
false,
(attempt) => {
result = attempt
}
)
return result ?? false
}
function partialSuccessAuth(
config: ConnectConfig,
authsLeft: AuthenticationType[]
): AuthenticationType | AnyAuthMethod | false {
let result: AuthenticationType | AnyAuthMethod | false | undefined
const handler = config.authHandler as AuthHandlerMiddleware
handler(authsLeft, true, (attempt) => {
result = attempt
})
return result ?? false
}
describe('ordered SSH private-key authentication', () => {
beforeEach(() => {
vi.stubEnv('SSH_AUTH_SOCK', '')
mockExistsSync.mockReset()
mockExistsSync.mockReturnValue(false)
mockReadFileSync.mockReset()
mockReadFileSync.mockImplementation((path: unknown) => Buffer.from(String(path)))
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllEnvs()
})
it('offers every fresh ssh -G IdentityFile in order and ignores the imported snapshot', () => {
const config = buildConnectConfig(makeTarget(), makeResolved(), {
includeAgent: false,
includePrivateKey: true
})
expect(nextAuth(config, true)).toMatchObject({ type: 'none' })
expect(nextAuth(config, false)).toMatchObject({
type: 'publickey',
key: Buffer.from('/keys/unauthorized-first')
})
expect(nextAuth(config, false)).toMatchObject({
type: 'publickey',
key: Buffer.from('/keys/authorized-second')
})
expect(nextAuth(config, false)).toBe('keyboard-interactive')
expect(nextAuth(config, false)).toBe(false)
expect(mockReadFileSync).not.toHaveBeenCalledWith('/keys/stale-imported')
})
it('still offers the ssh-agent when no readable key precedes it', () => {
vi.stubEnv('SSH_AUTH_SOCK', '/tmp/agent.sock')
const config = buildConnectConfig(makeTarget({ identityFile: undefined }), null)
expect(nextAuth(config, true)).toMatchObject({ type: 'none' })
expect(nextAuth(config, false)).toMatchObject({ type: 'agent', agent: '/tmp/agent.sock' })
expect(nextAuth(config, false)).toBe('keyboard-interactive')
})
it('keeps explicit manual keys and unresolved imported keys as singular overrides', () => {
const manual = buildConnectConfig(
makeTarget({
source: 'manual',
configHost: 'manual.example.com',
host: 'manual.example.com',
identityFile: '/keys/manual'
}),
makeResolved(),
{ includeAgent: false, includePrivateKey: true }
)
const unresolvedImport = buildConnectConfig(makeTarget(), null, {
includeAgent: false,
includePrivateKey: true
})
expect(manual.privateKey).toEqual(Buffer.from('/keys/manual'))
expect(unresolvedImport.privateKey).toEqual(Buffer.from('/keys/stale-imported'))
// Single-key targets are still ordered by Orca's handler so an MFA host reaches
// keyboard-interactive once per stage rather than once per connection.
expect(nextAuth(manual, true)).toMatchObject({ type: 'none' })
expect(nextAuth(manual, false)).toMatchObject({
type: 'publickey',
key: Buffer.from('/keys/manual')
})
expect(nextAuth(manual, false)).toBe('keyboard-interactive')
})
it('re-offers keyboard-interactive for each partial-success MFA stage', () => {
const config = buildConnectConfig(makeTarget(), makeResolved(), {
includeAgent: false,
includePrivateKey: true
})
config.password = 'stage-one'
expect(nextAuth(config, true)).toMatchObject({ type: 'none' })
expect(nextAuth(config, false)).toMatchObject({ type: 'password' })
// Partial success: the host accepted the password and now offers only the challenge.
expect(partialSuccessAuth(config, ['keyboard-interactive'])).toBe('keyboard-interactive')
expect(partialSuccessAuth(config, ['keyboard-interactive'])).toBe('keyboard-interactive')
})
it('stops re-offering methods the host no longer accepts after a partial success', () => {
const config = buildConnectConfig(makeTarget(), makeResolved(), {
includeAgent: false,
includePrivateKey: true
})
expect(nextAuth(config, true)).toMatchObject({ type: 'none' })
expect(partialSuccessAuth(config, ['keyboard-interactive'])).toBe('keyboard-interactive')
expect(nextAuth(config, false)).toBe(false)
})
it('bounds the number of partial-success stages it will answer', () => {
const config = buildConnectConfig(makeTarget(), makeResolved(), {
includeAgent: false,
includePrivateKey: true
})
expect(nextAuth(config, true)).toMatchObject({ type: 'none' })
for (let stage = 0; stage < 4; stage += 1) {
expect(partialSuccessAuth(config, ['keyboard-interactive'])).toBe('keyboard-interactive')
}
expect(partialSuccessAuth(config, ['keyboard-interactive'])).toBe(false)
})
it('offers every resolved key for a manually owned config-picker target', () => {
const config = buildConnectConfig(
makeTarget({
source: 'manual',
configHost: 'prod',
host: 'prod.internal',
identityFile: undefined
}),
makeResolved(),
{ includeAgent: false, includePrivateKey: true }
)
expect(nextAuth(config, true)).toMatchObject({ type: 'none' })
expect(nextAuth(config, false)).toMatchObject({
type: 'publickey',
key: Buffer.from('/keys/unauthorized-first')
})
expect(nextAuth(config, false)).toMatchObject({
type: 'publickey',
key: Buffer.from('/keys/authorized-second')
})
})
it('resets ordered authentication for credential retries without extra key reads', () => {
const config = buildConnectConfig(makeTarget(), makeResolved(), {
includeAgent: false,
includePrivateKey: true
})
const readsAfterResolution = mockReadFileSync.mock.calls.length
expect(nextAuth(config, true)).toMatchObject({ type: 'none' })
config.password = 'retry-password'
expect(nextAuth(config, true)).toMatchObject({ type: 'none' })
expect(nextAuth(config, false)).toMatchObject({
type: 'password',
password: 'retry-password'
})
expect(nextAuth(config, false)).toMatchObject({
type: 'publickey',
key: Buffer.from('/keys/unauthorized-first')
})
expect(mockReadFileSync).toHaveBeenCalledTimes(readsAfterResolution)
})
it('keeps encrypted-key prompts tied to the matching fresh identity path', () => {
vi.spyOn(utils, 'parseKey').mockImplementation((key) => {
if (
Buffer.from(key as Buffer)
.toString()
.includes('encrypted-second')
) {
return new Error('Encrypted private OpenSSH key detected, but no passphrase given')
}
return { isPrivateKey: () => true } as ParsedKey
})
const config = buildConnectConfig(
makeTarget(),
makeResolved({ identityFile: ['/keys/first', '/keys/encrypted-second'] }),
{ includeAgent: false, includePrivateKey: true }
)
expect(getPassphrasePrivateKeyPath(config)).toBe('/keys/encrypted-second')
})
it('leaves config-host key authority to system OpenSSH', () => {
const args = buildSshArgs(makeTarget(), { resolvedConfig: makeResolved() })
expect(args.at(-1)).toBe('workbox')
expect(args).not.toContain('-i')
expect(args).not.toContain('/keys/stale-imported')
expect(args).not.toContain('/keys/unauthorized-first')
expect(args).not.toContain('/keys/authorized-second')
})
})