581 lines
15 KiB
JavaScript
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()
|
|
}
|
|
})
|
|
})
|