505 lines
17 KiB
JavaScript
505 lines
17 KiB
JavaScript
/**
|
||
* SSH agent integration tests.
|
||
*
|
||
* These tests confirm that SSH agent authentication continues to work
|
||
* correctly across the following scenarios that are NOT covered by the
|
||
* existing session-ssh.spec.js suite:
|
||
*
|
||
* 1. Agent when sshKeysPath contains NON-matching key files.
|
||
* The createAuthHandler isMethodAllowed fix (commit f05ef847) makes the
|
||
* agent method eligible whenever the server advertises "publickey". A
|
||
* regression to that fix would cause all agent logins to fail silently
|
||
* whenever the user has any key files on disk.
|
||
*
|
||
* 2. Agent discovered via process.env.SSH_AUTH_SOCK (no explicit sshAgent
|
||
* option). The existing tests always pass sshAgent explicitly; this
|
||
* covers the implicit path used by most desktop setups.
|
||
*
|
||
* 3. exports.test() (connection probe) with SSH agent.
|
||
* Before commit a357c4a3 the ws object was not forwarded to the probe,
|
||
* so the host-key verification prompt (added in 409f3291) would wait
|
||
* forever when connecting to a new host.
|
||
*
|
||
* All tests mock the electron environment and run exclusively with Node.js
|
||
* built-ins + the packages already bundled with electerm.
|
||
*/
|
||
|
||
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, test: sshTest } = require('../../src/app/server/session-ssh')
|
||
|
||
// ─── constants ────────────────────────────────────────────────────────────────
|
||
|
||
const USERNAME = 'agent-tester'
|
||
|
||
// A fresh server host key for every test run.
|
||
const HOST_KEY = utils.generateKeyPairSync('ed25519', {
|
||
comment: 'electerm-agent-test-host'
|
||
})
|
||
|
||
// ─── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function parseKey (key) {
|
||
let parsed = utils.parseKey(key)
|
||
if (Array.isArray(parsed)) parsed = parsed[0]
|
||
if (parsed instanceof Error) throw parsed
|
||
return parsed
|
||
}
|
||
|
||
function publicKeyMatchesCtx (ctx, publicKeyPem) {
|
||
return Buffer.compare(
|
||
parseKey(publicKeyPem).getPublicSSH(),
|
||
ctx.key.data
|
||
) === 0
|
||
}
|
||
|
||
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(' ')} exited ${result.status}: ${result.stderr || result.stdout}`
|
||
)
|
||
}
|
||
return result.stdout
|
||
}
|
||
|
||
function generateKey ({ dir, name, type = 'ed25519', 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 ssh-agent on a unique socket and load the given key into it.
|
||
* Returns { env, kill }.
|
||
*/
|
||
function startAgent (keyPath) {
|
||
// Use a socket path that is predictable enough for debugging but unique.
|
||
const socketPath = path.join(
|
||
os.tmpdir(),
|
||
`ea-agent-${process.pid}-${Date.now()}.sock`
|
||
)
|
||
const output = runCommand('ssh-agent', ['-a', socketPath, '-s'])
|
||
const sockMatch = output.match(/SSH_AUTH_SOCK=([^;]+)/)
|
||
const pidMatch = output.match(/SSH_AGENT_PID=([^;]+)/)
|
||
if (!sockMatch || !pidMatch) {
|
||
throw new Error(`Cannot parse ssh-agent output:\n${output}`)
|
||
}
|
||
const env = {
|
||
...process.env,
|
||
SSH_AUTH_SOCK: sockMatch[1],
|
||
SSH_AGENT_PID: pidMatch[1]
|
||
}
|
||
if (keyPath) {
|
||
runCommand('ssh-add', [keyPath], { env })
|
||
}
|
||
return {
|
||
env,
|
||
kill () {
|
||
try { runCommand('ssh-agent', ['-k'], { env }) } catch (_) { /* ignore */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Start a minimal SSH server that only accepts publickey auth for USERNAME
|
||
* using the provided public-key string.
|
||
*
|
||
* Returns { port, close }.
|
||
*/
|
||
async function startServer (allowedPublicKey, { debug = false } = {}) {
|
||
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) => {
|
||
if (debug) {
|
||
console.log('[server] auth method:', ctx.method, 'user:', ctx.username)
|
||
}
|
||
if (ctx.method === 'none') {
|
||
return ctx.reject(['publickey'])
|
||
}
|
||
if (
|
||
ctx.method === 'publickey' &&
|
||
ctx.username === USERNAME &&
|
||
publicKeyMatchesCtx(ctx, allowedPublicKey)
|
||
) {
|
||
if (debug) console.log('[server] accepted publickey auth')
|
||
return ctx.accept()
|
||
}
|
||
// reject but keep publickey in the advertised list so the client can
|
||
// try the ssh agent after failing with a file key
|
||
return ctx.reject(['publickey'])
|
||
})
|
||
|
||
client.on('ready', () => {
|
||
client.on('session', (accept) => {
|
||
const sess = accept()
|
||
sess.on('env', (accept) => accept())
|
||
sess.on('pty', (accept) => accept())
|
||
sess.on('shell', (accept) => {
|
||
const stream = accept()
|
||
stream.write('electerm-agent-test ready\n')
|
||
})
|
||
})
|
||
})
|
||
})
|
||
|
||
server.listen(0, '127.0.0.1')
|
||
await once(server, 'listening')
|
||
const { port } = server.address()
|
||
if (debug) console.log('[server] listening on port', port)
|
||
|
||
return {
|
||
port,
|
||
async close () {
|
||
for (const c of clients) c.end()
|
||
await new Promise((resolve, reject) => {
|
||
server.close((err) => (err ? reject(err) : resolve()))
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Minimal ws mock that automatically trusts unknown host keys.
|
||
* Any non-confirm interactive prompt rejects with an error so tests fail
|
||
* loudly if an unexpected prompt appears.
|
||
*/
|
||
function makeWs ({ allowNonConfirmPrompts = false, debug = false } = {}) {
|
||
let pendingHandler = null
|
||
let pendingOptions = null
|
||
return {
|
||
s (payload) {
|
||
if (payload?.action !== 'session-interactive') return
|
||
pendingOptions = payload.options
|
||
if (debug) {
|
||
console.log('[ws] session-interactive', JSON.stringify({
|
||
mode: payload.options?.mode,
|
||
name: payload.options?.name,
|
||
prompts: (payload.options?.prompts || []).map(p => p.prompt)
|
||
}))
|
||
}
|
||
},
|
||
once (handler) {
|
||
const opts = pendingOptions
|
||
queueMicrotask(() => {
|
||
if (opts?.mode === 'confirm') {
|
||
// Auto-trust the host key
|
||
if (debug) console.log('[ws] auto-trusting host key')
|
||
handler({ results: ['trust'] })
|
||
} else if (allowNonConfirmPrompts) {
|
||
handler({ results: pendingHandler ? pendingHandler(opts) : [] })
|
||
} else {
|
||
// Fail loudly – agent login should not need interactive prompts
|
||
handler({ results: [] }) // empty results → reject('User cancel')
|
||
}
|
||
})
|
||
pendingHandler = null
|
||
},
|
||
close () {}
|
||
}
|
||
}
|
||
|
||
function setEnv (name, value) {
|
||
if (value === undefined) delete process.env[name]
|
||
else process.env[name] = value
|
||
}
|
||
|
||
// ─── fixture management ────────────────────────────────────────────────────────
|
||
|
||
describe('SSH agent auth', () => {
|
||
let tmpDir
|
||
let savedEnv
|
||
|
||
beforeEach(() => {
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electerm-agent-test-'))
|
||
|
||
savedEnv = {
|
||
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 })
|
||
|
||
setEnv('HOME', homeDir)
|
||
setEnv('USERPROFILE', homeDir)
|
||
setEnv('SSH_AUTH_SOCK', undefined)
|
||
setEnv('SSH_AGENT_PID', undefined)
|
||
setEnv('sshKeysPath', sshKeysDir)
|
||
})
|
||
|
||
afterEach(() => {
|
||
setEnv('HOME', savedEnv.HOME)
|
||
setEnv('USERPROFILE', savedEnv.USERPROFILE)
|
||
setEnv('SSH_AUTH_SOCK', savedEnv.SSH_AUTH_SOCK)
|
||
setEnv('SSH_AGENT_PID', savedEnv.SSH_AGENT_PID)
|
||
setEnv('sshKeysPath', savedEnv.sshKeysPath)
|
||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||
})
|
||
|
||
// ── test 1 ─────────────────────────────────────────────────────────────────
|
||
test(
|
||
'agent auth succeeds even when sshKeysPath contains non-matching key files',
|
||
async (t) => {
|
||
// This is the primary regression test for the isMethodAllowed fix
|
||
// (commit f05ef847). Without that fix the auth handler would return
|
||
// false as soon as the key-file publickey attempt failed, because
|
||
// "agent" is not literally in the server's ["publickey"] allow-list,
|
||
// and the connection would be rejected with "All configured
|
||
// authentication methods failed" before the agent was ever tried.
|
||
|
||
let agent
|
||
try {
|
||
agent = startAgent() // start without a key first – we load it below
|
||
} catch (err) {
|
||
if (err.code === 'ENOENT') {
|
||
t.skip('ssh-agent / ssh-keygen not available')
|
||
return
|
||
}
|
||
throw err
|
||
}
|
||
|
||
const sshKeysDir = process.env.sshKeysPath
|
||
|
||
// Generate the key the server will accept and load it into the agent.
|
||
const agentKey = generateKey({ dir: tmpDir, name: 'agent-key' })
|
||
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
|
||
|
||
// Generate a DIFFERENT key that lives in sshKeysPath.
|
||
// The code will try this one first (publickey auth) and it will fail.
|
||
// Agent must then be tried automatically.
|
||
const wrongKey = generateKey({ dir: sshKeysDir, name: 'wrong-key' })
|
||
// Also place the matching .pub file so getSSHKeys() finds it.
|
||
fs.copyFileSync(`${wrongKey.keyPath}.pub`, path.join(sshKeysDir, 'wrong-key.pub'))
|
||
|
||
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
|
||
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
|
||
|
||
const server = await startServer(agentKey.publicKey, { debug: true })
|
||
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: 10000,
|
||
debug: true // enables ssh2 protocol debug output
|
||
}, makeWs({ debug: true }))
|
||
|
||
// If we reach here the agent key was accepted.
|
||
assert.ok(term, 'session should resolve when agent auth succeeds')
|
||
} finally {
|
||
term?.kill()
|
||
await server.close()
|
||
agent.kill()
|
||
}
|
||
}
|
||
)
|
||
|
||
// ── test 2 ─────────────────────────────────────────────────────────────────
|
||
test(
|
||
'agent auth works via implicit process.env.SSH_AUTH_SOCK (no explicit sshAgent option)',
|
||
async (t) => {
|
||
// The getAgent() method falls back to process.env.SSH_AUTH_SOCK when
|
||
// initOptions.sshAgent is not set. This covers the typical desktop
|
||
// user who relies on a system-managed agent socket.
|
||
|
||
let agent
|
||
try {
|
||
agent = startAgent()
|
||
} catch (err) {
|
||
if (err.code === 'ENOENT') {
|
||
t.skip('ssh-agent / ssh-keygen not available')
|
||
return
|
||
}
|
||
throw err
|
||
}
|
||
|
||
const agentKey = generateKey({ dir: tmpDir, name: 'implicit-agent-key' })
|
||
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
|
||
|
||
// Expose the agent socket via the environment variable only – do NOT
|
||
// set initOptions.sshAgent.
|
||
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
|
||
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
|
||
|
||
const server = await startServer(agentKey.publicKey, { debug: true })
|
||
let term
|
||
try {
|
||
term = await session({
|
||
host: '127.0.0.1',
|
||
port: server.port,
|
||
username: USERNAME,
|
||
// No sshAgent option – should discover SSH_AUTH_SOCK automatically
|
||
useSshAgent: true,
|
||
enableSsh: false,
|
||
readyTimeout: 10000,
|
||
debug: true
|
||
}, makeWs({ debug: true }))
|
||
|
||
assert.ok(term, 'session should resolve when implicit agent is used')
|
||
} finally {
|
||
term?.kill()
|
||
await server.close()
|
||
agent.kill()
|
||
}
|
||
}
|
||
)
|
||
|
||
// ── test 3 ─────────────────────────────────────────────────────────────────
|
||
test(
|
||
'exports.test (connection probe) returns true with SSH agent on a new host',
|
||
async (t) => {
|
||
// Before commit a357c4a3, exports.test() did not forward ws to the
|
||
// TerminalSsh instance. The host-key verifier added in 409f3291 calls
|
||
// onKeyboardEvent() which sends a session-interactive event to ws and
|
||
// then awaits a reply. With ws === undefined the await never resolved,
|
||
// causing the probe to hang. This test would time out under that bug.
|
||
|
||
let agent
|
||
try {
|
||
agent = startAgent()
|
||
} catch (err) {
|
||
if (err.code === 'ENOENT') {
|
||
t.skip('ssh-agent / ssh-keygen not available')
|
||
return
|
||
}
|
||
throw err
|
||
}
|
||
|
||
const agentKey = generateKey({ dir: tmpDir, name: 'probe-agent-key' })
|
||
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
|
||
|
||
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
|
||
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
|
||
|
||
const server = await startServer(agentKey.publicKey, { debug: true })
|
||
try {
|
||
const ok = await sshTest({
|
||
host: '127.0.0.1',
|
||
port: server.port,
|
||
username: USERNAME,
|
||
sshAgent: agent.env.SSH_AUTH_SOCK,
|
||
useSshAgent: true,
|
||
readyTimeout: 10000,
|
||
debug: true
|
||
}, makeWs({ debug: true })) // ws must be forwarded (fixed in a357c4a3)
|
||
|
||
assert.equal(ok, true, 'sshTest should return true when agent auth succeeds')
|
||
} finally {
|
||
await server.close()
|
||
agent.kill()
|
||
}
|
||
}
|
||
)
|
||
|
||
// ── test 4 ─────────────────────────────────────────────────────────────────
|
||
test(
|
||
'agent auth with a new host requires host-key confirmation and then succeeds',
|
||
async (t) => {
|
||
// Confirm that the host-key verification dialog (409f3291) does not
|
||
// interfere with subsequent agent-based authentication. A fresh HOME
|
||
// dir is used so known_hosts starts empty.
|
||
|
||
let agent
|
||
try {
|
||
agent = startAgent()
|
||
} catch (err) {
|
||
if (err.code === 'ENOENT') {
|
||
t.skip('ssh-agent / ssh-keygen not available')
|
||
return
|
||
}
|
||
throw err
|
||
}
|
||
|
||
const agentKey = generateKey({ dir: tmpDir, name: 'new-host-agent-key' })
|
||
runCommand('ssh-add', [agentKey.keyPath], { env: agent.env })
|
||
|
||
setEnv('SSH_AUTH_SOCK', agent.env.SSH_AUTH_SOCK)
|
||
setEnv('SSH_AGENT_PID', agent.env.SSH_AGENT_PID)
|
||
|
||
const server = await startServer(agentKey.publicKey, { debug: true })
|
||
|
||
let confirmCount = 0
|
||
const ws = {
|
||
s (payload) {
|
||
if (payload?.action !== 'session-interactive') return
|
||
if (payload.options?.mode === 'confirm') {
|
||
confirmCount++
|
||
console.log('[ws] host-key confirm dialog received (#' + confirmCount + ')')
|
||
} else {
|
||
// Any non-confirm prompt during agent login is unexpected
|
||
console.error('[ws] unexpected non-confirm prompt:', payload.options)
|
||
}
|
||
},
|
||
once (handler) {
|
||
queueMicrotask(() => handler({ results: ['trust'] }))
|
||
},
|
||
close () {}
|
||
}
|
||
|
||
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: 10000,
|
||
debug: true
|
||
}, ws)
|
||
|
||
assert.ok(term, 'session should resolve after trusting the host key')
|
||
assert.equal(
|
||
confirmCount,
|
||
1,
|
||
'exactly one host-key confirmation prompt should appear for a new host'
|
||
)
|
||
|
||
// Verify the host was saved to known_hosts
|
||
const knownHostsPath = path.join(process.env.HOME, '.ssh', 'known_hosts')
|
||
const knownHostsContent = fs.readFileSync(knownHostsPath, 'utf8')
|
||
assert.match(
|
||
knownHostsContent,
|
||
/127\.0\.0\.1/,
|
||
'server should be recorded in known_hosts after trusting'
|
||
)
|
||
} finally {
|
||
term?.kill()
|
||
await server.close()
|
||
agent.kill()
|
||
}
|
||
}
|
||
)
|
||
})
|