Files
orca/src/main/ssh/ssh-multi-factor-authentication.test.ts
T
OrcaWinandm4air 5f308bfa9c revert: take the 26 Phase 3 (#16741 port) PRs back out of main (#24559)
* Revert "feat(orcad): source-side dormant export of a relay-hosted SSH target (#16741 T6-8) (#24519)"

This reverts commit 783101b304.

* Revert "feat(ssh): update, roll back, recover and stop a managed orcad server (#16741 T6-5 follow-up) (#24463)"

This reverts commit 38c2d1dcb9.

* Revert "feat(ssh): deploy and pair an empty managed orcad server over SSH (#16741 T6-5) (#24453)"

This reverts commit 8b76683b40.

* Revert "fix(ssh): orcad GC honors the activation journal; readiness requires proven daemon coverage (#16741 T6 follow-up) (#24451)"

This reverts commit d3f8c5063b.

* Revert "feat(ssh): remote orcad stop by request file and journaled decommission (#16741 T6-4) (#24449)"

This reverts commit 43d9b43d3f.

* Revert "feat(orcad): supervisable server: stop requests, managed stop receipts and a lifetime that keeps its lock on failed teardown (#16741 T6-3) (#24433)"

This reverts commit b093d3ab20.

* Revert "feat(ssh): crash-safe orcad activation, rollback and recovery (#16741 T6-2) (#24423)"

This reverts commit 1a9ac0e955.

* Revert "feat(runtime): SSH access links for paired servers in a downgrade-safe sidecar (#16741 T5-1+T5-2) (#24420)"

This reverts commit 99db2bfae4.

* Revert "feat(relay): capability-gated owner reset with a durable preparation journal (#16741 T3 R1) (#24418)"

This reverts commit 34a582bd39.

* Revert "feat(ssh): track connection-manager drains, test probes and provider continuations (#16741 T2 P3+P8a) (#24407)"

This reverts commit d53063d2b1.

* Revert "feat(daemon): idle retirement, session census and recovery-only provider (#16741 T2 P4b) (#24409)"

This reverts commit ff212dbbef.

* Revert "feat(ssh): add pty.resumeClient and split SSH PTY process listing (#16741 T2 P5+P6) (#24414)"

This reverts commit 92cb71765e.

* Revert "feat(relay): await owned watcher and agent children on shutdown (#16741 T2 P1) (#24400)"

This reverts commit 6b36e4f85b.

* Revert "feat(session): retry failed renderer session writes and verify local folder PTYs (#16741 T2 P9) (#24406)"

This reverts commit d23ecef301.

* Revert "feat(ssh): remote orcad primitives on the pinned Node runtime (#16741 T6-1) (#24419)"

This reverts commit dd87ae578d.

* Revert "fix(runtime): fence runtime-environment subscriptions and status probes by identity (#16741 T5-3) (#24421)"

This reverts commit ece9e4d2e3.

* Revert "feat(orcad): migration manifest and dormant-state contracts (#16741 T6-7) (#24422)"

This reverts commit 3fbdaba262.

* Revert "feat(ssh): wire SshConnection through the work and transport close ledgers (#16741 T2 P2) (#24401)"

This reverts commit 4e8edc8872.

* Revert "feat(profiles): carry markdown frontmatter visibility in project transfers (#16741 T2 P7) (#24405)"

This reverts commit 60c93263cc.

* Revert "fix(runtime): project the PTY incarnation onto mobile session tabs (#24413)"

This reverts commit 99e0303572.

* Revert "feat(daemon): tag daemon stream data with the PTY incarnation id (#16741 T2 P4a) (#24402)"

This reverts commit 817af768b0.

* Revert "feat(ssh): port the SSH connection work ledger and transport close ledger (#16741 T2) (#24210)"

This reverts commit c9918931c8.

* Revert "feat(relay): fence and drain file and git response streams on shutdown (#24185)"

This reverts commit dc08ffeba9.

* Revert "refactor(runtime-rpc): extract the Node WebSocket lifecycle; opt-in pinned port (#24186)"

This reverts commit a789233bbb.

* Revert "feat(relay): route relay handlers through work admission; producer publication drain (#24181)"

This reverts commit 0b812bd698.

* Revert "feat(relay): land the #16741 T1 seam (work drain, publication drain, release gate) (#24156)"

This reverts commit 3aa2d3af7c.

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-10-02 00:52:32 -07:00

270 lines
8.5 KiB
TypeScript

import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
Client,
Server as Ssh2Server,
utils,
type AuthContext,
type Connection,
type KeyboardAuthContext,
type PasswordAuthContext
} from 'ssh2'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { SshTarget } from '../../shared/ssh-types'
import type { SshResolvedConfig } from './ssh-config-parser'
import { buildConnectConfig } from './ssh-connection-utils'
// OpenSSH's default; a host that burns it disconnects before the MFA stage is reached.
const MAX_AUTH_TRIES = 6
const PASSWORD = 'stage-one-password'
const PASSCODE = '123456'
type AuthStage = 'password' | 'keyboard-interactive'
type MfaServer = {
port: number
attempts: string[]
close: () => Promise<void>
}
/** An OpenSSH-style `AuthenticationMethods a,b` host: each stage partial-succeeds into the next. */
async function startMultiFactorServer(stages: AuthStage[]): Promise<MfaServer> {
const attempts: string[] = []
const connections = new Set<Connection>()
// Ed25519 keygen can produce an invalid 31-byte key; ECDSA points always start with 0x04.
const hostKey = utils.generateKeyPairSync('ecdsa', { bits: 256 }).private
const server = new Ssh2Server({ hostKeys: [hostKey] }, (connection) => {
connections.add(connection)
connection.on('error', () => {})
connection.on('close', () => connections.delete(connection))
let stage = 0
let failures = 0
const remaining = (): AuthStage[] => [stages[stage]!]
const fail = (context: AuthContext): void => {
failures += 1
if (failures >= MAX_AUTH_TRIES) {
connection.end()
return
}
context.reject(remaining(), false)
}
connection.on('authentication', (context) => {
attempts.push(context.method)
if (context.method === 'none') {
context.reject(remaining(), false)
return
}
if (context.method !== stages[stage]) {
fail(context)
return
}
if (context.method === 'password') {
if ((context as PasswordAuthContext).password !== PASSWORD) {
fail(context)
return
}
stage += 1
if (stage === stages.length) {
context.accept()
return
}
context.reject(remaining(), true)
return
}
const keyboard = context as KeyboardAuthContext
keyboard.prompt(
[{ prompt: 'Duo passcode:', echo: false }],
'Duo two-factor login',
'Approve the push or enter a passcode.',
(answers) => {
if (answers?.[0] !== PASSCODE) {
fail(context)
return
}
stage += 1
if (stage === stages.length) {
context.accept()
return
}
context.reject(remaining(), true)
}
)
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.removeListener('error', reject)
resolve()
})
})
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('MFA fixture did not bind a TCP port')
}
return {
port: address.port,
attempts,
close: async () => {
for (const connection of connections) {
connection.end()
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()))
})
}
}
}
function makeTarget(port: number, overrides: Partial<SshTarget> = {}): SshTarget {
return {
id: 'mfa-target',
label: 'hpc',
source: 'manual',
host: '127.0.0.1',
port,
username: 'fixture',
...overrides
}
}
function makeResolved(port: number, identityFile: string[]): SshResolvedConfig {
return {
hostname: '127.0.0.1',
port,
user: 'fixture',
identityFile,
identitiesOnly: true,
forwardAgent: false,
proxyUseFdpass: false,
controlMaster: 'no',
controlPersist: 'no',
userKnownHostsFiles: [],
globalKnownHostsFiles: [],
strictHostKeyChecking: 'ask',
hashKnownHosts: false,
updateHostKeys: 'no'
}
}
/** Drives ssh2 the way SshConnection does: one credential per keyboard-interactive prompt. */
function connectWithOrcaConfig(
target: SshTarget,
resolved: SshResolvedConfig | null,
password: string | undefined,
answers: string[]
): { ready: Promise<void>; prompts: string[] } {
const prompts: string[] = []
const config = buildConnectConfig(target, resolved, {
includeAgent: false,
includePrivateKey: true
})
if (password != null) {
config.password = password
}
const ready = new Promise<void>((resolve, reject) => {
const client = new Client()
let answerIndex = 0
client.on('keyboard-interactive', (_name, _instructions, _lang, requested, finish) => {
for (const requestedPrompt of requested) {
prompts.push(requestedPrompt.prompt)
}
finish(requested.map(() => answers[answerIndex++] ?? ''))
})
client.once('ready', () => {
client.end()
resolve()
})
client.once('error', reject)
client.once('close', () => reject(new Error('SSH connection closed during authentication')))
client.connect({ ...config, hostVerifier: () => true, readyTimeout: 10_000 })
})
return { ready, prompts }
}
describe('multi-stage SSH authentication', () => {
let tempDir: string
let keyPaths: string[]
let homeEnv: { HOME?: string; USERPROFILE?: string }
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'orca-mfa-'))
// Why: the cases below pass `resolved: null`, so `resolvePrivateKeys` falls through to
// `findDefaultKeyFile`, which reads `~/.ssh/id_*` through `homedir()`. On a developer
// machine that picks up a real key, and an encrypted one makes ssh2 reject with
// "Cannot parse privateKey" before authentication is exercised at all. Hosted CI has no
// key, so this only ever failed locally. Pointing home at the fixture directory keeps
// default-key discovery inside the test's control on every machine.
homeEnv = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }
process.env.HOME = tempDir
process.env.USERPROFILE = tempDir
keyPaths = ['id_a', 'id_b'].map((name) => {
const path = join(tempDir, name)
writeFileSync(path, utils.generateKeyPairSync('ecdsa', { bits: 256 }).private)
return path
})
})
afterEach(() => {
for (const key of ['HOME', 'USERPROFILE'] as const) {
const previous = homeEnv[key]
if (previous === undefined) {
delete process.env[key]
} else {
process.env[key] = previous
}
}
rmSync(tempDir, { recursive: true, force: true })
})
it('answers a keyboard-interactive stage that follows a password partial success', async () => {
const server = await startMultiFactorServer(['password', 'keyboard-interactive'])
try {
const { ready, prompts } = connectWithOrcaConfig(makeTarget(server.port), null, PASSWORD, [
PASSCODE
])
await expect(ready).resolves.toBeUndefined()
expect(prompts).toEqual(['Duo passcode:'])
} finally {
await server.close()
}
})
it('answers a second keyboard-interactive stage after the first partially succeeds', async () => {
const server = await startMultiFactorServer(['keyboard-interactive', 'keyboard-interactive'])
try {
const { ready, prompts } = connectWithOrcaConfig(makeTarget(server.port), null, undefined, [
PASSCODE,
PASSCODE
])
await expect(ready).resolves.toBeUndefined()
expect(prompts).toEqual(['Duo passcode:', 'Duo passcode:'])
} finally {
await server.close()
}
})
it('reaches the MFA stage without burning the host auth-try budget on rejected keys', async () => {
const server = await startMultiFactorServer(['password', 'keyboard-interactive'])
try {
const target = makeTarget(server.port, { source: 'ssh-config', configHost: 'hpc' })
const { ready } = connectWithOrcaConfig(
target,
makeResolved(server.port, keyPaths),
PASSWORD,
[PASSCODE]
)
await expect(ready).resolves.toBeUndefined()
// After the password stage partially succeeds the host only offers keyboard-interactive;
// re-offering keys there is what exhausts MaxAuthTries on real MFA hosts.
expect(server.attempts.filter((method) => method === 'publickey')).toHaveLength(0)
} finally {
await server.close()
}
})
})