chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,580 @@
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user