Files
2026-07-13 12:46:20 +08:00

581 lines
15 KiB
JavaScript

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 { setTimeout: delay } = require('node:timers/promises')
const { Server, utils } = require('@electerm/ssh2')
const { session } = require('../../src/app/server/session-ssh')
const USERNAME = 'tester'
const PASSWORD = 'electerm-test'
const OTP = '123456'
const PASSPHRASE = 'electerm-passphrase'
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')
}
}
async function startServer (authHandler) {
const clients = new Set()
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) => {
authHandler(ctx)
})
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,
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 (promptResponder, options = {}) {
const {
includeConfirmPrompts = false,
defaultConfirmResponse = ['trust']
} = options
const prompts = []
let pendingOptions
return {
prompts,
s (payload) {
if (payload && payload.action === 'session-interactive') {
pendingOptions = payload.options
if (includeConfirmPrompts || payload.options?.mode !== 'confirm') {
prompts.push(payload.options)
}
}
},
once (handler) {
const currentOptions = pendingOptions
queueMicrotask(() => {
handler({
results: currentOptions?.mode === 'confirm' && !includeConfirmPrompts
? defaultConfirmResponse
: promptResponder(currentOptions, prompts)
})
})
},
close () {}
}
}
function publicKeyOnlyAuth (publicKey) {
return (ctx) => {
if (ctx.method === 'none') {
return ctx.reject(['publickey'])
}
if (ctx.method === 'publickey' && ctx.username === USERNAME && matchesPublicKey(ctx, publicKey)) {
return ctx.accept()
}
return ctx.reject(['publickey'])
}
}
function sameRoundKeyboardInteractiveAuth () {
return (ctx) => {
if (ctx.method === 'none') {
return ctx.reject(['keyboard-interactive'])
}
if (ctx.method === 'keyboard-interactive' && ctx.username === USERNAME) {
return ctx.prompt([
{ prompt: 'Password:', echo: false },
{ prompt: 'Verification code:', echo: false }
], 'electerm-test', 'same-round otp', (responses) => {
if (responses[0] === PASSWORD && responses[1] === OTP) {
ctx.accept()
} else {
ctx.reject(['keyboard-interactive'])
}
})
}
return ctx.reject(['keyboard-interactive'])
}
}
function splitRoundKeyboardInteractiveAuth (rounds = []) {
return (ctx) => {
if (ctx.method === 'none') {
return ctx.reject(['keyboard-interactive'])
}
if (ctx.method === 'keyboard-interactive' && ctx.username === USERNAME) {
rounds.push('otp')
return ctx.prompt([
{ prompt: 'Verification code:', echo: false }
], 'electerm-test', 'otp round', (otpResponses) => {
if (otpResponses[0] !== OTP) {
return ctx.reject(['keyboard-interactive'])
}
rounds.push('password')
ctx.prompt([
{ prompt: 'Password:', echo: false }
], 'electerm-test', 'password round', (passwordResponses) => {
if (passwordResponses[0] === PASSWORD) {
ctx.accept()
} else {
ctx.reject(['keyboard-interactive'])
}
})
})
}
return ctx.reject(['keyboard-interactive'])
}
}
function startAgent () {
const socketPath = path.join(os.tmpdir(), `ea-${process.pid}-${Date.now()}.sock`)
const output = runCommand('ssh-agent', ['-a', socketPath, '-s'])
const sock = output.match(/SSH_AUTH_SOCK=([^;]+)/)
const pid = output.match(/SSH_AGENT_PID=([^;]+)/)
if (!sock || !pid) {
throw new Error(`Unable to parse ssh-agent output: ${output}`)
}
const env = {
...process.env,
SSH_AUTH_SOCK: sock[1],
SSH_AGENT_PID: pid[1]
}
return {
env,
kill () {
runCommand('ssh-agent', ['-k'], { env })
}
}
}
describe('session-ssh auth flows', () => {
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('connects with an rsa key protected by a passphrase', async () => {
const keyPair = generateClientKey({
dir: tmpDir,
name: 'rsa-passphrase',
type: 'rsa',
bits: 2048,
passphrase: PASSPHRASE
})
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
const ws = createPromptWs(() => {
throw new Error('explicit passphrase login should not prompt')
})
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
privateKey: keyPair.privateKey,
passphrase: PASSPHRASE,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, ws)
assert.equal(ws.prompts.length, 0)
} finally {
term && term.kill()
await server.close()
}
})
test('connects with an ed25519 key protected by a passphrase', async () => {
const keyPair = generateClientKey({
dir: tmpDir,
name: 'ed25519-passphrase',
type: 'ed25519',
passphrase: PASSPHRASE
})
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
const ws = createPromptWs(() => {
throw new Error('explicit passphrase login should not prompt')
})
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
privateKey: keyPair.privateKey,
passphrase: PASSPHRASE,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, ws)
assert.equal(ws.prompts.length, 0)
} finally {
term && term.kill()
await server.close()
}
})
test('handles password and otp in the same keyboard-interactive round', async () => {
const server = await startServer(sameRoundKeyboardInteractiveAuth())
const ws = createPromptWs((options) => {
assert.equal(options.prompts.length, 2)
return [PASSWORD, OTP]
})
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.equal(ws.prompts.length, 1)
assert.match(ws.prompts[0].prompts[1].prompt, /verification code/i)
} finally {
term && term.kill()
await server.close()
}
})
test('handles otp then password across separate keyboard-interactive rounds', async () => {
const rounds = []
const server = await startServer(splitRoundKeyboardInteractiveAuth(rounds))
const ws = createPromptWs((options, prompts) => {
if (prompts.length === 1) {
assert.match(options.prompts[0].prompt, /verification code/i)
return [OTP]
}
assert.match(options.prompts[0].prompt, /password/i)
return [PASSWORD]
})
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.deepEqual(rounds, ['otp', 'otp', 'password'])
assert.equal(ws.prompts.length, 1)
assert.match(ws.prompts[0].prompts[0].prompt, /verification code/i)
} finally {
term && term.kill()
await server.close()
}
})
test('connects with ssh agent only and leaves user keys untouched', async (t) => {
let agent
try {
agent = startAgent()
} catch (error) {
if (error.code === 'ENOENT') {
t.skip('ssh-agent is not available in this environment')
}
throw error
}
const keyPair = generateClientKey({
dir: tmpDir,
name: 'agent-ed25519',
type: 'ed25519',
passphrase: ''
})
try {
runCommand('ssh-add', [keyPair.keyPath], { env: agent.env })
setEnvVar('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
setEnvVar('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
let term
try {
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
sshAgent: agent.env.SSH_AUTH_SOCK,
useSshAgent: true,
enableSsh: false,
readyTimeout: 5000
}, createPromptWs(() => {
throw new Error('ssh-agent login should not prompt')
}))
} finally {
term && term.kill()
await server.close()
}
} finally {
if (agent) {
agent.kill()
}
}
})
test('prompts for an unknown host key once and records it in known_hosts', async () => {
const keyPair = generateClientKey({
dir: tmpDir,
name: 'host-verify-ed25519',
type: 'ed25519',
passphrase: ''
})
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
const knownHostsPath = path.join(process.env.HOME, '.ssh', 'known_hosts')
let term
let firstPromptCount = 0
try {
const ws = createPromptWs((options, prompts) => {
firstPromptCount = prompts.length
assert.equal(options.mode, 'confirm')
assert.match(options.name, /trust ssh host key/i)
assert.match(options.instructions.join('\n'), /Fingerprint: SHA256:/)
return ['trust']
}, {
includeConfirmPrompts: true
})
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
privateKey: keyPair.privateKey,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, ws)
assert.equal(firstPromptCount, 1)
assert.match(fs.readFileSync(knownHostsPath, 'utf8'), /^\[127\.0\.0\.1\]:\d+ ssh-ed25519 /)
term.kill()
term = null
term = await session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
privateKey: keyPair.privateKey,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, createPromptWs(() => {
throw new Error('known_hosts match should not prompt again')
}))
} finally {
term && term.kill()
await server.close()
}
})
test('waits for host trust confirmation before resolving the session', async () => {
const keyPair = generateClientKey({
dir: tmpDir,
name: 'host-verify-blocking-ed25519',
type: 'ed25519',
passphrase: ''
})
const server = await startServer(publicKeyOnlyAuth(keyPair.publicKey))
let term
let pendingHandler
let sessionResolved = false
let promptSeenResolve
const promptSeen = new Promise(resolve => {
promptSeenResolve = resolve
})
const ws = {
prompts: [],
s (payload) {
if (payload && payload.action === 'session-interactive') {
this.prompts.push(payload.options)
promptSeenResolve(payload.options)
}
},
once (handler) {
pendingHandler = handler
},
close () {}
}
try {
const sessionPromise = session({
host: '127.0.0.1',
port: server.port,
username: USERNAME,
privateKey: keyPair.privateKey,
useSshAgent: false,
enableSsh: false,
readyTimeout: 5000
}, ws).then(result => {
sessionResolved = true
return result
})
const promptOptions = await promptSeen
assert.equal(promptOptions.mode, 'confirm')
await delay(50)
assert.equal(sessionResolved, false)
assert.equal(typeof pendingHandler, 'function')
pendingHandler({
results: ['trust']
})
term = await sessionPromise
} finally {
term && term.kill()
await server.close()
}
})
})