463 lines
13 KiB
JavaScript
463 lines
13 KiB
JavaScript
/**
|
|
* Test: when password is provided, password auth should be tried first
|
|
* before publickey/agent, even if ~/.ssh has keys available.
|
|
*/
|
|
|
|
process.env.NODE_ENV = 'development'
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test')
|
|
const assert = require('node:assert/strict')
|
|
const fs = require('node:fs')
|
|
const os = require('node:os')
|
|
const path = require('node:path')
|
|
const { once } = require('node:events')
|
|
const { spawnSync } = require('node:child_process')
|
|
const { Server, utils } = require('@electerm/ssh2')
|
|
const { session } = require('../../src/app/server/session-ssh')
|
|
|
|
const USERNAME = 'tester'
|
|
const PASSWORD = 'electerm-test-password'
|
|
|
|
const HOST_KEY = utils.generateKeyPairSync('ed25519', {
|
|
comment: 'electerm-test-host'
|
|
})
|
|
|
|
function parseKey (key, passphrase) {
|
|
let parsed = utils.parseKey(key, passphrase)
|
|
if (Array.isArray(parsed)) {
|
|
parsed = parsed[0]
|
|
}
|
|
if (parsed instanceof Error) {
|
|
throw parsed
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
function matchesPublicKey (ctx, publicKey) {
|
|
return Buffer.compare(
|
|
parseKey(publicKey).getPublicSSH(),
|
|
ctx.key.data
|
|
) === 0
|
|
}
|
|
|
|
function makeTmpDir () {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-ssh-test-'))
|
|
}
|
|
|
|
function setEnvVar (name, value) {
|
|
if (value === undefined) {
|
|
delete process.env[name]
|
|
} else {
|
|
process.env[name] = value
|
|
}
|
|
}
|
|
|
|
function runCommand (command, args, options = {}) {
|
|
const result = spawnSync(command, args, {
|
|
encoding: 'utf8',
|
|
...options
|
|
})
|
|
if (result.error) {
|
|
throw result.error
|
|
}
|
|
if (result.status !== 0) {
|
|
throw new Error(`${command} ${args.join(' ')} failed: ${result.stderr || result.stdout}`)
|
|
}
|
|
return result.stdout
|
|
}
|
|
|
|
function generateClientKey ({ dir, name, type, passphrase, bits }) {
|
|
const keyPath = path.join(dir, name)
|
|
const args = ['-q', '-t', type]
|
|
|
|
if (bits) {
|
|
args.push('-b', String(bits))
|
|
}
|
|
|
|
args.push(
|
|
'-N',
|
|
passphrase || '',
|
|
'-f',
|
|
keyPath,
|
|
'-C',
|
|
`electerm-${name}`
|
|
)
|
|
|
|
runCommand('ssh-keygen', args)
|
|
|
|
return {
|
|
keyPath,
|
|
privateKey: fs.readFileSync(keyPath, 'utf8'),
|
|
publicKey: fs.readFileSync(`${keyPath}.pub`, 'utf8')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start an SSH server that tracks which auth methods are attempted.
|
|
* @param {string} publicKey - The public key to accept for publickey auth
|
|
* @returns {{ port: number, authAttempts: string[], close: () => Promise<void> }}
|
|
*/
|
|
async function startTrackingServer (publicKey) {
|
|
const clients = new Set()
|
|
const authAttempts = []
|
|
|
|
const server = new Server({
|
|
hostKeys: [HOST_KEY.private]
|
|
}, (client) => {
|
|
clients.add(client)
|
|
const cleanup = () => clients.delete(client)
|
|
|
|
client.on('close', cleanup)
|
|
client.on('end', cleanup)
|
|
client.on('authentication', (ctx) => {
|
|
authAttempts.push(ctx.method)
|
|
|
|
if (ctx.method === 'none') {
|
|
return ctx.reject(['publickey', 'password'])
|
|
}
|
|
|
|
// Accept password auth
|
|
if (ctx.method === 'password' && ctx.username === USERNAME && ctx.password === PASSWORD) {
|
|
return ctx.accept()
|
|
}
|
|
|
|
// Accept publickey auth
|
|
if (ctx.method === 'publickey' && ctx.username === USERNAME && matchesPublicKey(ctx, publicKey)) {
|
|
return ctx.accept()
|
|
}
|
|
|
|
ctx.reject(['publickey', 'password'])
|
|
})
|
|
client.on('ready', () => {
|
|
client.on('session', (accept) => {
|
|
const sshSession = accept()
|
|
sshSession.on('env', (accept) => accept())
|
|
sshSession.on('pty', (accept) => accept())
|
|
sshSession.on('shell', (accept) => {
|
|
const stream = accept()
|
|
stream.write('electerm ready\n')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
server.listen(0, '127.0.0.1')
|
|
await once(server, 'listening')
|
|
|
|
return {
|
|
port: server.address().port,
|
|
authAttempts,
|
|
async close () {
|
|
for (const client of clients) {
|
|
client.end()
|
|
}
|
|
await new Promise((resolve, reject) => {
|
|
server.close((err) => {
|
|
if (err) {
|
|
reject(err)
|
|
} else {
|
|
resolve()
|
|
}
|
|
})
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
function createPromptWs () {
|
|
return {
|
|
prompts: [],
|
|
s (payload) {},
|
|
once (handler) {
|
|
queueMicrotask(() => {
|
|
handler({ results: ['trust'] })
|
|
})
|
|
},
|
|
close () {}
|
|
}
|
|
}
|
|
|
|
describe('session-ssh password-first auth', () => {
|
|
let tmpDir
|
|
let oldEnv
|
|
|
|
beforeEach(() => {
|
|
tmpDir = makeTmpDir()
|
|
oldEnv = {
|
|
HOME: process.env.HOME,
|
|
USERPROFILE: process.env.USERPROFILE,
|
|
SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK,
|
|
SSH_AGENT_PID: process.env.SSH_AGENT_PID,
|
|
sshKeysPath: process.env.sshKeysPath
|
|
}
|
|
|
|
const homeDir = path.join(tmpDir, 'home')
|
|
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
|
|
fs.mkdirSync(homeDir, { recursive: true })
|
|
fs.mkdirSync(sshKeysDir, { recursive: true })
|
|
|
|
setEnvVar('HOME', homeDir)
|
|
setEnvVar('USERPROFILE', homeDir)
|
|
setEnvVar('SSH_AUTH_SOCK', undefined)
|
|
setEnvVar('SSH_AGENT_PID', undefined)
|
|
setEnvVar('sshKeysPath', sshKeysDir)
|
|
})
|
|
|
|
afterEach(() => {
|
|
setEnvVar('HOME', oldEnv.HOME)
|
|
setEnvVar('USERPROFILE', oldEnv.USERPROFILE)
|
|
setEnvVar('SSH_AUTH_SOCK', oldEnv.SSH_AUTH_SOCK)
|
|
setEnvVar('SSH_AGENT_PID', oldEnv.SSH_AGENT_PID)
|
|
setEnvVar('sshKeysPath', oldEnv.sshKeysPath)
|
|
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
test('when password is provided, password auth is tried before publickey from ~/.ssh keys', async () => {
|
|
// Generate a key pair and put it in the sshKeysPath (simulating ~/.ssh)
|
|
const keyPair = generateClientKey({
|
|
dir: tmpDir,
|
|
name: 'id_ed25519',
|
|
type: 'ed25519',
|
|
passphrase: ''
|
|
})
|
|
|
|
// Copy public key to sshKeysDir so getSSHKeys() finds it
|
|
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
|
|
fs.copyFileSync(keyPair.keyPath, path.join(sshKeysDir, 'id_ed25519'))
|
|
fs.copyFileSync(`${keyPair.keyPath}.pub`, path.join(sshKeysDir, 'id_ed25519.pub'))
|
|
|
|
// Start server that accepts both password and publickey
|
|
const server = await startTrackingServer(keyPair.publicKey)
|
|
const ws = createPromptWs()
|
|
|
|
let term
|
|
try {
|
|
term = await session({
|
|
host: '127.0.0.1',
|
|
port: server.port,
|
|
username: USERNAME,
|
|
password: PASSWORD,
|
|
useSshAgent: false,
|
|
enableSsh: false,
|
|
readyTimeout: 5000
|
|
}, ws)
|
|
|
|
// Verify the connection succeeded
|
|
assert.ok(term, 'session should be created')
|
|
|
|
// The key assertion: password should be the first real auth attempt
|
|
// (after 'none' which is always first)
|
|
const authMethods = server.authAttempts
|
|
const firstRealAuth = authMethods.find(m => m !== 'none')
|
|
assert.equal(firstRealAuth, 'password',
|
|
`Expected password to be tried first, but auth attempts were: ${JSON.stringify(authMethods)}`)
|
|
|
|
// Password should appear before any publickey attempt
|
|
const passwordIndex = authMethods.indexOf('password')
|
|
const publickeyIndex = authMethods.indexOf('publickey')
|
|
if (publickeyIndex !== -1) {
|
|
assert.ok(passwordIndex < publickeyIndex,
|
|
`password (index ${passwordIndex}) should be tried before publickey (index ${publickeyIndex}), auth attempts: ${JSON.stringify(authMethods)}`)
|
|
}
|
|
} finally {
|
|
if (term) {
|
|
term.kill()
|
|
}
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
test('when password is provided with agent available, password is still tried first', async () => {
|
|
// Generate a key pair and put it in sshKeysPath
|
|
const keyPair = generateClientKey({
|
|
dir: tmpDir,
|
|
name: 'id_rsa',
|
|
type: 'rsa',
|
|
bits: 2048,
|
|
passphrase: ''
|
|
})
|
|
|
|
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
|
|
fs.copyFileSync(keyPair.keyPath, path.join(sshKeysDir, 'id_rsa'))
|
|
fs.copyFileSync(`${keyPair.keyPath}.pub`, path.join(sshKeysDir, 'id_rsa.pub'))
|
|
|
|
// Start server that accepts both password and publickey
|
|
const server = await startTrackingServer(keyPair.publicKey)
|
|
const ws = createPromptWs()
|
|
|
|
let term
|
|
try {
|
|
term = await session({
|
|
host: '127.0.0.1',
|
|
port: server.port,
|
|
username: USERNAME,
|
|
password: PASSWORD,
|
|
// Don't disable agent - let it be set naturally
|
|
// useSshAgent defaults to checking SSH_AUTH_SOCK
|
|
enableSsh: false,
|
|
readyTimeout: 5000
|
|
}, ws)
|
|
|
|
assert.ok(term, 'session should be created')
|
|
|
|
const authMethods = server.authAttempts
|
|
const firstRealAuth = authMethods.find(m => m !== 'none')
|
|
assert.equal(firstRealAuth, 'password',
|
|
`Expected password to be tried first, but auth attempts were: ${JSON.stringify(authMethods)}`)
|
|
} finally {
|
|
if (term) {
|
|
term.kill()
|
|
}
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
test('when only password is provided (no keys in ~/.ssh), password auth works', async () => {
|
|
// No keys in sshKeysDir - only password available
|
|
const server = await startTrackingServer(null)
|
|
const ws = createPromptWs()
|
|
|
|
let term
|
|
try {
|
|
term = await session({
|
|
host: '127.0.0.1',
|
|
port: server.port,
|
|
username: USERNAME,
|
|
password: PASSWORD,
|
|
useSshAgent: false,
|
|
enableSsh: false,
|
|
readyTimeout: 5000
|
|
}, ws)
|
|
|
|
assert.ok(term, 'session should be created')
|
|
|
|
const authMethods = server.authAttempts
|
|
assert.ok(authMethods.includes('password'),
|
|
`password auth should have been attempted, auth attempts: ${JSON.stringify(authMethods)}`)
|
|
} finally {
|
|
if (term) {
|
|
term.kill()
|
|
}
|
|
await server.close()
|
|
}
|
|
})
|
|
|
|
test('with isMFA and password provided, ~/.ssh keys are NOT loaded (no passphrase dialog)', async () => {
|
|
// Generate a key with passphrase - simulating ~/.ssh key that would trigger passphrase dialog
|
|
const keyPair = generateClientKey({
|
|
dir: tmpDir,
|
|
name: 'id_ed25519_passphrase',
|
|
type: 'ed25519',
|
|
passphrase: 'key-passphrase'
|
|
})
|
|
|
|
// Copy key to sshKeysDir so getSSHKeys() would find it
|
|
const sshKeysDir = path.join(tmpDir, 'ssh-keys')
|
|
fs.copyFileSync(keyPair.keyPath, path.join(sshKeysDir, 'id_ed25519_passphrase'))
|
|
fs.copyFileSync(`${keyPair.keyPath}.pub`, path.join(sshKeysDir, 'id_ed25519_passphrase.pub'))
|
|
|
|
// Server only accepts password (simulating bastion host)
|
|
const authAttempts = []
|
|
const clients = new Set()
|
|
const server = new Server({
|
|
hostKeys: [HOST_KEY.private]
|
|
}, (client) => {
|
|
clients.add(client)
|
|
client.on('close', () => clients.delete(client))
|
|
client.on('end', () => clients.delete(client))
|
|
client.on('authentication', (ctx) => {
|
|
authAttempts.push(ctx.method)
|
|
if (ctx.method === 'none') {
|
|
return ctx.reject(['keyboard-interactive', 'password'])
|
|
}
|
|
if (ctx.method === 'password' && ctx.username === USERNAME && ctx.password === PASSWORD) {
|
|
return ctx.accept()
|
|
}
|
|
if (ctx.method === 'keyboard-interactive' && ctx.username === USERNAME) {
|
|
return ctx.prompt(
|
|
[{ prompt: 'Password:', echo: false }],
|
|
'instructions',
|
|
'lang',
|
|
(responses) => {
|
|
if (responses[0] === PASSWORD) {
|
|
ctx.accept()
|
|
} else {
|
|
ctx.reject(['keyboard-interactive', 'password'])
|
|
}
|
|
}
|
|
)
|
|
}
|
|
ctx.reject(['keyboard-interactive', 'password'])
|
|
})
|
|
client.on('ready', () => {
|
|
client.on('session', (accept) => {
|
|
const sshSession = accept()
|
|
sshSession.on('env', (accept) => accept())
|
|
sshSession.on('pty', (accept) => accept())
|
|
sshSession.on('shell', (accept) => {
|
|
const stream = accept()
|
|
stream.write('electerm ready\n')
|
|
})
|
|
})
|
|
})
|
|
})
|
|
|
|
server.listen(0, '127.0.0.1')
|
|
await once(server, 'listening')
|
|
const port = server.address().port
|
|
|
|
// Track if any passphrase prompt appears
|
|
let passphrasePrompted = false
|
|
const ws = {
|
|
prompts: [],
|
|
s (payload) {
|
|
if (payload && payload.action === 'session-interactive') {
|
|
this.prompts.push(payload.options)
|
|
// Check if this is a passphrase prompt
|
|
if (payload.options?.prompts?.[0]?.prompt?.toLowerCase().includes('passphrase') ||
|
|
payload.options?.name?.toLowerCase().includes('passphase')) {
|
|
passphrasePrompted = true
|
|
}
|
|
}
|
|
},
|
|
once (handler) {
|
|
queueMicrotask(() => {
|
|
handler({ results: ['trust'] })
|
|
})
|
|
},
|
|
close () {}
|
|
}
|
|
|
|
let term
|
|
try {
|
|
term = await session({
|
|
host: '127.0.0.1',
|
|
port,
|
|
username: USERNAME,
|
|
password: PASSWORD,
|
|
isMFA: true,
|
|
useSshAgent: false,
|
|
enableSsh: false,
|
|
readyTimeout: 5000
|
|
}, ws)
|
|
|
|
assert.ok(term, 'session should be created')
|
|
assert.equal(passphrasePrompted, false,
|
|
'Should NOT prompt for key passphrase when password is provided in bookmark')
|
|
|
|
// Verify password or keyboard-interactive was used, NOT publickey
|
|
const hasPublickey = authAttempts.includes('publickey')
|
|
assert.equal(hasPublickey, false,
|
|
`publickey auth should NOT be attempted when password is provided, auth attempts: ${JSON.stringify(authAttempts)}`)
|
|
} finally {
|
|
if (term) {
|
|
term.kill()
|
|
}
|
|
for (const client of clients) {
|
|
client.end()
|
|
}
|
|
await new Promise((resolve) => server.close(resolve))
|
|
}
|
|
})
|
|
})
|