From 043caf28d20ee1c29dafef510da9f488d30d8566 Mon Sep 17 00:00:00 2001 From: Artem Smirnov Date: Mon, 3 Aug 2026 11:44:21 +0300 Subject: [PATCH] fix(devtools-proxy-support): support SSH password auth via `keyboard-interactive` --- .../src/ssh-auth.spec.ts | 180 ++++++++ .../devtools-proxy-support/src/ssh-auth.ts | 54 +++ .../devtools-proxy-support/src/ssh.spec.ts | 417 ++++++++++++++++++ packages/devtools-proxy-support/src/ssh.ts | 45 +- .../devtools-proxy-support/test/helpers.ts | 117 ++++- 5 files changed, 808 insertions(+), 5 deletions(-) create mode 100644 packages/devtools-proxy-support/src/ssh-auth.spec.ts create mode 100644 packages/devtools-proxy-support/src/ssh-auth.ts diff --git a/packages/devtools-proxy-support/src/ssh-auth.spec.ts b/packages/devtools-proxy-support/src/ssh-auth.spec.ts new file mode 100644 index 000000000..3338fd44a --- /dev/null +++ b/packages/devtools-proxy-support/src/ssh-auth.spec.ts @@ -0,0 +1,180 @@ +import { expect } from 'chai'; +import { createSshAuthMethodSelector } from './ssh-auth'; + +describe('createSshAuthMethodSelector', function () { + it('only attempts none without configured credentials', function () { + const selectAuthMethod = createSshAuthMethodSelector({ + hasPassword: false, + hasPrivateKey: false, + }); + + expect(selectAuthMethod(null, null)).to.equal('none'); + expect(selectAuthMethod([], false)).to.equal(false); + }); + + it('preserves the password authentication order', function () { + const selectAuthMethod = createSshAuthMethodSelector({ + hasPassword: true, + hasPrivateKey: false, + }); + + expect(selectAuthMethod(null, null)).to.equal('none'); + expect( + selectAuthMethod(['password', 'keyboard-interactive'], false), + ).to.equal('password'); + expect(selectAuthMethod(['keyboard-interactive'], false)).to.equal( + 'keyboard-interactive', + ); + expect(selectAuthMethod([], false)).to.equal(false); + }); + + it('preserves the public key authentication order', function () { + const selectAuthMethod = createSshAuthMethodSelector({ + hasPassword: false, + hasPrivateKey: true, + }); + + expect(selectAuthMethod(null, null)).to.equal('none'); + expect(selectAuthMethod(['publickey'], false)).to.equal('publickey'); + expect(selectAuthMethod([], false)).to.equal(false); + }); + + it('preserves the combined password and public key order', function () { + const selectAuthMethod = createSshAuthMethodSelector({ + hasPassword: true, + hasPrivateKey: true, + }); + + expect(selectAuthMethod(null, null)).to.equal('none'); + expect( + selectAuthMethod( + ['password', 'publickey', 'keyboard-interactive'], + false, + ), + ).to.equal('password'); + expect( + selectAuthMethod(['publickey', 'keyboard-interactive'], false), + ).to.equal('publickey'); + expect(selectAuthMethod(['keyboard-interactive'], false)).to.equal( + 'keyboard-interactive', + ); + expect(selectAuthMethod([], false)).to.equal(false); + }); + + it('does not let methodsLeft change the current preference order', function () { + const selectAuthMethod = createSshAuthMethodSelector({ + hasPassword: true, + hasPrivateKey: true, + }); + + expect(selectAuthMethod(null, null)).to.equal('none'); + expect(selectAuthMethod(['publickey'], false)).to.equal('password'); + expect(selectAuthMethod(['keyboard-interactive'], false)).to.equal( + 'publickey', + ); + expect(selectAuthMethod([], false)).to.equal('keyboard-interactive'); + }); + + it('excludes keyboard-interactive after a partially successful password', function () { + const selectAuthMethod = createSshAuthMethodSelector({ + hasPassword: true, + hasPrivateKey: false, + }); + + expect(selectAuthMethod(null, null)).to.equal('none'); + expect( + selectAuthMethod(['password', 'keyboard-interactive'], false), + ).to.equal('password'); + expect(selectAuthMethod(['keyboard-interactive'], true)).to.equal(false); + }); + + it('excludes keyboard-interactive after a partially successful public key', function () { + const selectAuthMethod = createSshAuthMethodSelector({ + hasPassword: true, + hasPrivateKey: true, + }); + + expect(selectAuthMethod(null, null)).to.equal('none'); + expect( + selectAuthMethod( + ['password', 'publickey', 'keyboard-interactive'], + false, + ), + ).to.equal('password'); + expect( + selectAuthMethod(['publickey', 'keyboard-interactive'], false), + ).to.equal('publickey'); + expect(selectAuthMethod(['keyboard-interactive'], true)).to.equal(false); + }); + + it('continues with a configured standard method after partial success', function () { + const selectAuthMethod = createSshAuthMethodSelector({ + hasPassword: true, + hasPrivateKey: true, + }); + + expect(selectAuthMethod(null, null)).to.equal('none'); + expect( + selectAuthMethod( + ['password', 'publickey', 'keyboard-interactive'], + false, + ), + ).to.equal('password'); + expect( + selectAuthMethod(['publickey', 'keyboard-interactive'], true), + ).to.equal('publickey'); + }); + + it('keeps partial success sticky after a later failed method', function () { + const selectAuthMethod = createSshAuthMethodSelector({ + hasPassword: true, + hasPrivateKey: true, + }); + + expect(selectAuthMethod(null, null)).to.equal('none'); + expect( + selectAuthMethod( + ['password', 'publickey', 'keyboard-interactive'], + false, + ), + ).to.equal('password'); + expect( + selectAuthMethod(['publickey', 'keyboard-interactive'], true), + ).to.equal('publickey'); + expect(selectAuthMethod(['keyboard-interactive'], false)).to.equal(false); + }); + + it('does not return exhausted methods again', function () { + const selectAuthMethod = createSshAuthMethodSelector({ + hasPassword: false, + hasPrivateKey: false, + }); + + expect(selectAuthMethod(null, null)).to.equal('none'); + expect(selectAuthMethod([], false)).to.equal(false); + expect(selectAuthMethod([], false)).to.equal(false); + }); + + it('creates independent state for every SSH connection', function () { + const options = { + hasPassword: true, + hasPrivateKey: false, + }; + const firstSelector = createSshAuthMethodSelector(options); + + expect(firstSelector(null, null)).to.equal('none'); + expect(firstSelector(['password', 'keyboard-interactive'], false)).to.equal( + 'password', + ); + expect(firstSelector(['keyboard-interactive'], true)).to.equal(false); + + const secondSelector = createSshAuthMethodSelector(options); + expect(secondSelector(null, null)).to.equal('none'); + expect( + secondSelector(['password', 'keyboard-interactive'], false), + ).to.equal('password'); + expect(secondSelector(['keyboard-interactive'], false)).to.equal( + 'keyboard-interactive', + ); + }); +}); diff --git a/packages/devtools-proxy-support/src/ssh-auth.ts b/packages/devtools-proxy-support/src/ssh-auth.ts new file mode 100644 index 000000000..2f3186466 --- /dev/null +++ b/packages/devtools-proxy-support/src/ssh-auth.ts @@ -0,0 +1,54 @@ +import type { AuthenticationType } from 'ssh2'; + +interface SshAuthMethodSelectorOptions { + hasPassword: boolean; + hasPrivateKey: boolean; +} + +export type SshAuthMethodSelector = ( + methodsLeft: AuthenticationType[] | null, + partialSuccess: boolean | null, +) => AuthenticationType | false; + +/** + * Creates the authentication policy for one SSH connection. + * + * The method order intentionally matches the subset of ssh2's default policy + * supported by SSHAgent. `methodsLeft` is accepted as part of ssh2's handler + * contract but does not influence selection yet; server-guided selection needs + * a separate compatibility strategy before it can safely replace this order. + */ +export function createSshAuthMethodSelector({ + hasPassword, + hasPrivateKey, +}: SshAuthMethodSelectorOptions): SshAuthMethodSelector { + const methods: AuthenticationType[] = ['none']; + if (hasPassword) { + methods.push('password'); + } + if (hasPrivateKey) { + methods.push('publickey'); + } + if (hasPassword) { + methods.push('keyboard-interactive'); + } + + let nextMethodIndex = 0; + let hasObservedPartialSuccess = false; + + return (_methodsLeft, partialSuccess) => { + if (partialSuccess === true) { + hasObservedPartialSuccess = true; + } + + while (nextMethodIndex < methods.length) { + const method = methods[nextMethodIndex++]; + if (method === 'keyboard-interactive' && hasObservedPartialSuccess) { + continue; + } + return method; + } + + return false; + }; +} diff --git a/packages/devtools-proxy-support/src/ssh.spec.ts b/packages/devtools-proxy-support/src/ssh.spec.ts index 39df990f2..65966c74f 100644 --- a/packages/devtools-proxy-support/src/ssh.spec.ts +++ b/packages/devtools-proxy-support/src/ssh.spec.ts @@ -5,6 +5,18 @@ import { createFetch } from './fetch'; import { expect } from 'chai'; import sinon from 'sinon'; +async function expectAuthenticationFailure(agent: SSHAgent): Promise { + try { + await agent.initialize(); + expect.fail('missed exception'); + } catch (err: unknown) { + expect(err).to.be.instanceOf(Error); + expect((err as Error).message).to.equal( + 'All configured authentication methods failed', + ); + } +} + describe('SSHAgent', function () { let setup: HTTPServerProxyTestSetup; let agent: SSHAgent | undefined; @@ -55,6 +67,411 @@ describe('SSHAgent', function () { expect(setup.authHandler).to.have.been.calledOnceWith('foo^', 'ba&r'); }); + it('does not send the password to keyboard-interactive after partial success', async function () { + const keyboardInteractiveResponses: string[][] = []; + setup.sshAuthenticationHandler = (ctx) => { + if (ctx.method === 'none') { + ctx.reject(['password', 'keyboard-interactive']); + return; + } + if (ctx.method === 'password') { + ctx.reject(['keyboard-interactive'], true); + return; + } + if (ctx.method === 'keyboard-interactive') { + ctx.prompt( + [{ prompt: 'Verification code: ', echo: false }], + (responses) => { + keyboardInteractiveResponses.push(responses); + ctx.reject(); + }, + ); + return; + } + ctx.reject(); + }; + agent = new SSHAgent({ + proxy: `ssh://foo:bar@127.0.0.1:${setup.sshProxyPort}/`, + }); + + await expectAuthenticationFailure(agent); + + expect(keyboardInteractiveResponses).to.deep.equal([]); + expect(setup.sshAuthenticationAttempts).to.deep.equal([ + { username: 'foo', method: 'none' }, + { username: 'foo', method: 'password' }, + ]); + }); + + it('keeps partial success sticky after a public key rejection', async function () { + const keyboardInteractiveResponses: string[][] = []; + setup.sshAuthenticationHandler = (ctx) => { + if (ctx.method === 'none') { + ctx.reject(['password', 'publickey', 'keyboard-interactive']); + return; + } + if (ctx.method === 'password') { + ctx.reject(['publickey', 'keyboard-interactive'], true); + return; + } + if (ctx.method === 'publickey') { + setup.handleTestSshPublicKeyAuthentication(ctx, () => { + ctx.reject(['keyboard-interactive'], false); + }); + return; + } + if (ctx.method === 'keyboard-interactive') { + ctx.prompt( + [{ prompt: 'Verification code: ', echo: false }], + (responses) => { + keyboardInteractiveResponses.push(responses); + ctx.reject(); + }, + ); + return; + } + ctx.reject(); + }; + agent = new SSHAgent({ + proxy: `ssh://foo:bar@127.0.0.1:${setup.sshProxyPort}/`, + sshOptions: { + identityKeyFile: setup.sshIdentityKeyFile, + }, + }); + + await expectAuthenticationFailure(agent); + + expect(keyboardInteractiveResponses).to.deep.equal([]); + expect(setup.sshAuthenticationAttempts).to.deep.equal([ + { username: 'foo', method: 'none' }, + { username: 'foo', method: 'password' }, + { username: 'foo', method: 'publickey' }, + { username: 'foo', method: 'publickey' }, + ]); + }); + + it('continues public key authentication after a partially successful password', async function () { + setup.sshAuthenticationHandler = (ctx) => { + if (ctx.method === 'none') { + ctx.reject(['password', 'publickey', 'keyboard-interactive']); + return; + } + if (ctx.method === 'password') { + ctx.reject(['publickey', 'keyboard-interactive'], true); + return; + } + if (ctx.method === 'publickey') { + setup.handleTestSshPublicKeyAuthentication(ctx, () => { + ctx.accept(); + }); + return; + } + ctx.reject(); + }; + agent = new SSHAgent({ + proxy: `ssh://foo:bar@127.0.0.1:${setup.sshProxyPort}/`, + sshOptions: { + identityKeyFile: setup.sshIdentityKeyFile, + }, + }); + + const response = await createFetch(agent)('http://example.com/hello'); + + expect(await response.text()).to.equal('OK /hello'); + expect(setup.sshAuthenticationAttempts).to.deep.equal([ + { username: 'foo', method: 'none' }, + { username: 'foo', method: 'password' }, + { username: 'foo', method: 'publickey' }, + { username: 'foo', method: 'publickey' }, + ]); + }); + + it('does not send the password after partial public key authentication', async function () { + const keyboardInteractiveResponses: string[][] = []; + setup.sshAuthenticationHandler = (ctx) => { + if (ctx.method === 'none') { + ctx.reject(['password', 'publickey', 'keyboard-interactive']); + return; + } + if (ctx.method === 'password') { + ctx.reject(['publickey', 'keyboard-interactive']); + return; + } + if (ctx.method === 'publickey') { + setup.handleTestSshPublicKeyAuthentication(ctx, () => { + ctx.reject(['keyboard-interactive'], true); + }); + return; + } + if (ctx.method === 'keyboard-interactive') { + ctx.prompt( + [{ prompt: 'Verification code: ', echo: false }], + (responses) => { + keyboardInteractiveResponses.push(responses); + ctx.reject(); + }, + ); + return; + } + ctx.reject(); + }; + agent = new SSHAgent({ + proxy: `ssh://foo:bar@127.0.0.1:${setup.sshProxyPort}/`, + sshOptions: { + identityKeyFile: setup.sshIdentityKeyFile, + }, + }); + + await expectAuthenticationFailure(agent); + + expect(keyboardInteractiveResponses).to.deep.equal([]); + expect(setup.sshAuthenticationAttempts).to.deep.equal([ + { username: 'foo', method: 'none' }, + { username: 'foo', method: 'password' }, + { username: 'foo', method: 'publickey' }, + { username: 'foo', method: 'publickey' }, + ]); + }); + + it('authenticates with only a configured public key', async function () { + setup.sshAuthenticationHandler = (ctx) => { + if (ctx.method === 'none') { + ctx.reject(['publickey']); + return; + } + if (ctx.method === 'publickey') { + setup.handleTestSshPublicKeyAuthentication(ctx, () => { + ctx.accept(); + }); + return; + } + ctx.reject(); + }; + agent = new SSHAgent({ + proxy: `ssh://foo@127.0.0.1:${setup.sshProxyPort}/`, + sshOptions: { + identityKeyFile: setup.sshIdentityKeyFile, + }, + }); + + const response = await createFetch(agent)('http://example.com/hello'); + + expect(await response.text()).to.equal('OK /hello'); + expect(setup.sshAuthenticationAttempts).to.deep.equal([ + { username: 'foo', method: 'none' }, + { username: 'foo', method: 'publickey' }, + { username: 'foo', method: 'publickey' }, + ]); + }); + + it('uses keyboard-interactive after a password rejection without partial success', async function () { + const keyboardInteractiveResponses: string[][] = []; + setup.sshAuthenticationHandler = (ctx) => { + if (ctx.method === 'none') { + ctx.reject(['password', 'keyboard-interactive']); + return; + } + if (ctx.method === 'password') { + ctx.reject(['keyboard-interactive']); + return; + } + if (ctx.method === 'keyboard-interactive') { + ctx.prompt([{ prompt: 'Password: ', echo: false }], (responses) => { + keyboardInteractiveResponses.push(responses); + if (responses.length === 1 && responses[0] === 'bar') { + ctx.accept(); + } else { + ctx.reject(); + } + }); + return; + } + ctx.reject(); + }; + agent = new SSHAgent({ + proxy: `ssh://foo:bar@127.0.0.1:${setup.sshProxyPort}/`, + }); + + const response = await createFetch(agent)('http://example.com/hello'); + + expect(await response.text()).to.equal('OK /hello'); + expect(keyboardInteractiveResponses).to.deep.equal([['bar']]); + expect(setup.sshAuthenticationAttempts).to.deep.equal([ + { username: 'foo', method: 'none' }, + { username: 'foo', method: 'password' }, + { username: 'foo', method: 'keyboard-interactive' }, + ]); + }); + + it('uses the password for a single hidden keyboard-interactive prompt', async function () { + setup.sshKeyboardInteractiveAuthRounds = [ + { + prompts: [{ prompt: 'Password: ', echo: false }], + expectedResponses: ['bar'], + }, + ]; + agent = new SSHAgent({ + proxy: `ssh://foo:bar@127.0.0.1:${setup.sshProxyPort}/`, + }); + + const response = await createFetch(agent)('http://example.com/hello'); + + expect(await response.text()).to.equal('OK /hello'); + expect(setup.sshKeyboardInteractiveAuthAttempts).to.deep.equal([ + { username: 'foo', responses: [['bar']] }, + ]); + }); + + it('does not consume the password on an empty keyboard-interactive round', async function () { + setup.sshKeyboardInteractiveAuthRounds = [ + { prompts: [], expectedResponses: [] }, + { + prompts: [{ prompt: 'Password: ', echo: false }], + expectedResponses: ['bar'], + }, + ]; + agent = new SSHAgent({ + proxy: `ssh://foo:bar@127.0.0.1:${setup.sshProxyPort}/`, + }); + + await agent.initialize(); + + expect(setup.sshKeyboardInteractiveAuthAttempts).to.deep.equal([ + { username: 'foo', responses: [[], ['bar']] }, + ]); + }); + + it('does not expose the password to multiple keyboard-interactive prompts', async function () { + setup.sshKeyboardInteractiveAuthRounds = [ + { + prompts: [ + { prompt: 'Password: ', echo: false }, + { prompt: 'Verification code: ', echo: false }, + ], + expectedResponses: ['', ''], + }, + ]; + agent = new SSHAgent({ + proxy: `ssh://foo:bar@127.0.0.1:${setup.sshProxyPort}/`, + }); + + await agent.initialize(); + + expect(setup.sshKeyboardInteractiveAuthAttempts).to.deep.equal([ + { username: 'foo', responses: [['', '']] }, + ]); + }); + + it('does not expose the password to a visible keyboard-interactive prompt', async function () { + setup.sshKeyboardInteractiveAuthRounds = [ + { + prompts: [{ prompt: 'Username: ', echo: true }], + expectedResponses: [''], + }, + ]; + agent = new SSHAgent({ + proxy: `ssh://foo:bar@127.0.0.1:${setup.sshProxyPort}/`, + }); + + await agent.initialize(); + + expect(setup.sshKeyboardInteractiveAuthAttempts).to.deep.equal([ + { username: 'foo', responses: [['']] }, + ]); + }); + + it('does not reuse the password for a later keyboard-interactive round', async function () { + setup.sshKeyboardInteractiveAuthRounds = [ + { + prompts: [{ prompt: 'Password: ', echo: false }], + expectedResponses: ['bar'], + }, + { + prompts: [{ prompt: 'Verification code: ', echo: false }], + expectedResponses: [''], + }, + ]; + agent = new SSHAgent({ + proxy: `ssh://foo:bar@127.0.0.1:${setup.sshProxyPort}/`, + }); + + await agent.initialize(); + + expect(setup.sshKeyboardInteractiveAuthAttempts).to.deep.equal([ + { username: 'foo', responses: [['bar'], ['']] }, + ]); + }); + + it('rejects an incorrect keyboard-interactive password', async function () { + setup.sshKeyboardInteractiveAuthRounds = [ + { + prompts: [{ prompt: 'Password: ', echo: false }], + expectedResponses: ['bar'], + }, + ]; + agent = new SSHAgent({ + proxy: `ssh://foo:wrong@127.0.0.1:${setup.sshProxyPort}/`, + }); + + try { + await agent.initialize(); + expect.fail('missed exception'); + } catch (err: any) { + expect(err.message).to.equal( + 'All configured authentication methods failed', + ); + } + expect(setup.sshKeyboardInteractiveAuthAttempts).to.deep.equal([ + { username: 'foo', responses: [['wrong']] }, + ]); + }); + + it('does not attempt keyboard-interactive authentication without a password', async function () { + setup.sshKeyboardInteractiveAuthRounds = [ + { + prompts: [{ prompt: 'Password: ', echo: false }], + expectedResponses: ['bar'], + }, + ]; + agent = new SSHAgent({ + proxy: `ssh://foo@127.0.0.1:${setup.sshProxyPort}/`, + }); + + try { + await agent.initialize(); + expect.fail('missed exception'); + } catch (err: any) { + expect(err.message).to.equal( + 'All configured authentication methods failed', + ); + } + expect(setup.sshKeyboardInteractiveAuthAttempts).to.have.length(0); + }); + + it('can use the password again when the SSH client reconnects', async function () { + setup.sshKeyboardInteractiveAuthRounds = [ + { + prompts: [{ prompt: 'Password: ', echo: false }], + expectedResponses: ['bar'], + }, + ]; + agent = new SSHAgent({ + proxy: `ssh://foo:bar@127.0.0.1:${setup.sshProxyPort}/`, + }); + const fetch = createFetch(agent); + + const firstResponse = await fetch('http://example.com/hello'); + expect(await firstResponse.text()).to.equal('OK /hello'); + await agent.interruptForTesting(); + const secondResponse = await fetch('http://example.com/hello'); + expect(await secondResponse.text()).to.equal('OK /hello'); + + expect(setup.sshKeyboardInteractiveAuthAttempts).to.deep.equal([ + { username: 'foo', responses: [['bar']] }, + { username: 'foo', responses: [['bar']] }, + ]); + }); + it('allows explicitly initializing the connection', async function () { setup.authHandler = sinon.stub().returns(true); agent = new SSHAgent({ diff --git a/packages/devtools-proxy-support/src/ssh.ts b/packages/devtools-proxy-support/src/ssh.ts index 143572a14..31e6a7691 100644 --- a/packages/devtools-proxy-support/src/ssh.ts +++ b/packages/devtools-proxy-support/src/ssh.ts @@ -12,6 +12,7 @@ import type { ProxyLogEmitter } from './logging'; import { connect as tlsConnect } from 'tls'; import type { Socket } from 'net'; import { getFips } from 'crypto'; +import { createSshAuthMethodSelector } from './ssh-auth'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports function ssh2(): typeof import('ssh2') { @@ -59,9 +60,38 @@ export class SSHAgent extends AgentBase implements AgentWithInitialize { this.sshClient = this.createSshClient(); } + private getPassword(): string | undefined { + return decodeURIComponent(this.url.password) || undefined; + } + private createSshClient(): SshClient { const client = new (ssh2().Client)(); + let handledNonEmptyKeyboardInteractiveRound = false; + client.on( + 'keyboard-interactive', + (_name, _instructions, _instructionsLang, prompts, finish) => { + if (prompts.length === 0) { + finish([]); + return; + } + + const password = + !handledNonEmptyKeyboardInteractiveRound && + prompts.length === 1 && + prompts[0].echo === false + ? this.getPassword() + : undefined; + handledNonEmptyKeyboardInteractiveRound = true; + + // Treat keyboard-interactive as a conservative password fallback, + // rather than a general interactive or multi-factor authentication UI. + finish(password ? [password] : prompts.map(() => '')); + }, + ); client.on('close', () => { + // A Client instance can reconnect after its socket closes, so keep this + // state scoped to a single SSH connection. + handledNonEmptyKeyboardInteractiveRound = false; this.logger.emit('ssh:client-closed'); this.connected = false; }); @@ -108,17 +138,24 @@ export class SSHAgent extends AgentBase implements AgentWithInitialize { return this.reinitializingPromise; } + const password = this.getPassword(); + const privateKey = this.proxyOptions.sshOptions?.identityKeyFile + ? await fs.readFile(this.proxyOptions.sshOptions.identityKeyFile) + : undefined; const sshConnectConfig: ConnectConfig = { readyTimeout: 20000, keepaliveInterval: 20000, host: decodeURIComponent(this.url.hostname), port: +this.url.port || 22, username: decodeURIComponent(this.url.username) || undefined, - password: decodeURIComponent(this.url.password) || undefined, - privateKey: this.proxyOptions.sshOptions?.identityKeyFile - ? await fs.readFile(this.proxyOptions.sshOptions.identityKeyFile) - : undefined, + password, + tryKeyboard: Boolean(password), + privateKey, passphrase: this.proxyOptions.sshOptions?.identityKeyPassphrase, + authHandler: createSshAuthMethodSelector({ + hasPassword: Boolean(password), + hasPrivateKey: Boolean(privateKey), + }), // debug: console.log.bind(null, '[client]') }; diff --git a/packages/devtools-proxy-support/test/helpers.ts b/packages/devtools-proxy-support/test/helpers.ts index b46288a19..e42e3f97a 100644 --- a/packages/devtools-proxy-support/test/helpers.ts +++ b/packages/devtools-proxy-support/test/helpers.ts @@ -10,8 +10,16 @@ import { createServer as createHTTPSServer } from 'https'; import type { AddressInfo, Server, Socket } from 'net'; import path from 'path'; import { createServer as createHTTPServer, get as httpGet } from 'http'; -import type { TcpipRequestInfo } from 'ssh2'; +import type { + AuthContext, + AuthenticationType, + ParsedKey, + Prompt, + PublicKeyAuthContext, + TcpipRequestInfo, +} from 'ssh2'; import { Server as SSHServer } from 'ssh2'; +import { utils as sshUtils } from 'ssh2'; import DuplexPair from 'duplexpair'; import { promisify } from 'util'; @@ -28,6 +36,14 @@ function parseHTTPAuthHeader(header: string | undefined): [string, string] { return [username, pw]; } +function parseTestSshKey(key: Buffer): ParsedKey { + const parsedKey = sshUtils.parseKey(key); + if (parsedKey instanceof Error) { + throw parsedKey; + } + return parsedKey; +} + export class HTTPServerProxyTestSetup { // Target servers: These actually handle requests. readonly httpServer: HTTPServer; @@ -44,8 +60,24 @@ export class HTTPServerProxyTestSetup { // hibernate). These are the underlying net.Sockets, not the high-level // ssh2 Client objects emitted by the SSH server's 'connection' event. readonly sshServerSockets: Socket[] = []; + readonly sshKeyboardInteractiveAuthAttempts: Array<{ + username: string; + responses: string[][]; + }> = []; + readonly sshAuthenticationAttempts: Array<{ + username: string; + method: AuthenticationType; + }> = []; + readonly sshIdentityKeyFile = path.resolve(__dirname, 'fixtures', 'sshd.key'); canTunnel: () => boolean = () => true; authHandler: undefined | ((username: string, password: string) => boolean); + sshAuthenticationHandler: undefined | ((ctx: AuthContext) => void); + sshKeyboardInteractiveAuthRounds: + | undefined + | Array<{ + prompts: Prompt[]; + expectedResponses: string[]; + }>; get httpServerPort(): number { return (this.httpServer.address() as AddressInfo).port; @@ -75,6 +107,41 @@ export class HTTPServerProxyTestSetup { ca: readFileSync(path.resolve(__dirname, 'fixtures', 'ca.crt')), sshdKey: readFileSync(path.resolve(__dirname, 'fixtures', 'sshd.key')), }); + private readonly sshIdentityKey = parseTestSshKey(this.tlsOptions.sshdKey); + + /** + * Handles the public key probe and verifies the signed request before + * delegating the final authentication outcome to the test. + */ + handleTestSshPublicKeyAuthentication( + ctx: PublicKeyAuthContext, + onAuthenticated: () => void, + ): void { + if ( + ctx.key.algo !== this.sshIdentityKey.type || + !ctx.key.data.equals(this.sshIdentityKey.getPublicSSH()) + ) { + ctx.reject(); + return; + } + + if (!ctx.signature) { + ctx.accept(); + return; + } + + const hashAlgorithm = (ctx as PublicKeyAuthContext & { hashAlgo?: string }) + .hashAlgo; + if ( + !ctx.blob || + !this.sshIdentityKey.verify(ctx.blob, ctx.signature, hashAlgorithm) + ) { + ctx.reject(); + return; + } + + onAuthenticated(); + } constructor() { this.requests = []; @@ -155,6 +222,54 @@ export class HTTPServerProxyTestSetup { (client) => { client .on('authentication', (ctx) => { + this.sshAuthenticationAttempts.push({ + username: ctx.username, + method: ctx.method, + }); + + if (this.sshAuthenticationHandler) { + this.sshAuthenticationHandler(ctx); + return; + } + + const keyboardInteractiveRounds = + this.sshKeyboardInteractiveAuthRounds; + if (keyboardInteractiveRounds) { + if (ctx.method !== 'keyboard-interactive') { + return ctx.reject(['keyboard-interactive']); + } + + const attempt = { + username: ctx.username, + responses: [] as string[][], + }; + this.sshKeyboardInteractiveAuthAttempts.push(attempt); + let roundIndex = 0; + const promptNextRound = (): void => { + const round = keyboardInteractiveRounds[roundIndex++]; + if (!round) { + ctx.accept(); + return; + } + ctx.prompt(round.prompts, (responses) => { + attempt.responses.push(responses); + const responsesMatch = + responses.length === round.expectedResponses.length && + responses.every( + (response, index) => + response === round.expectedResponses[index], + ); + if (!responsesMatch) { + ctx.reject(); + return; + } + promptNextRound(); + }); + }; + promptNextRound(); + return; + } + if (ctx.method === 'none' && !this.authHandler) return ctx.accept(); if ( ctx.method === 'password' &&