550 lines
16 KiB
JavaScript
550 lines
16 KiB
JavaScript
// TODO: add more rekey tests that at least include switching from no
|
|
// compression to compression and vice versa
|
|
'use strict'
|
|
|
|
const assert = require('./assert-compat.js')
|
|
const { spawn, spawnSync } = require('child_process')
|
|
const { chmodSync, readdirSync } = require('fs')
|
|
const { join } = require('path')
|
|
const readline = require('readline')
|
|
|
|
const Server = require('../lib/server.js')
|
|
|
|
const { fixture, fixtureKey, FIXTURES_DIR, mustCall, mustCallAtLeast } = require('./common.js')
|
|
|
|
const SPAWN_OPTS = { windowsHide: true }
|
|
const CLIENT_TIMEOUT = 5000
|
|
|
|
const debug = false
|
|
const opensshPath = 'ssh'
|
|
let opensshVer
|
|
|
|
// TODO: figure out why this test is failing on Windows
|
|
if (process.platform === 'win32') {
|
|
console.log('Skipping OpenSSH integration tests on Windows')
|
|
process.exit(0)
|
|
}
|
|
|
|
// Fix file modes to avoid OpenSSH client complaints about keys' permissions
|
|
for (const file of readdirSync(FIXTURES_DIR, { withFileTypes: true })) {
|
|
if (file.isFile()) chmodSync(join(FIXTURES_DIR, file.name), 0o600)
|
|
}
|
|
|
|
{
|
|
// Get OpenSSH client version first
|
|
const { error, stderr, stdout } = spawnSync(opensshPath, ['-V'], SPAWN_OPTS)
|
|
|
|
if (error) {
|
|
console.error('OpenSSH client is required for these tests')
|
|
process.exitCode = 5
|
|
return
|
|
}
|
|
|
|
const re = /^OpenSSH_([\d.]+)/
|
|
let m = re.exec(stdout.toString())
|
|
if (!m || !m[1]) {
|
|
m = re.exec(stderr.toString())
|
|
if (!m || !m[1]) {
|
|
console.error('OpenSSH client is required for these tests')
|
|
process.exitCode = 5
|
|
return
|
|
}
|
|
}
|
|
|
|
opensshVer = m[1]
|
|
console.log(`Testing with OpenSSH version: ${opensshVer}`)
|
|
}
|
|
|
|
// Key-based authentication
|
|
;[
|
|
{ desc: 'RSA user key (old OpenSSH)', clientKey: fixtureKey('id_rsa') },
|
|
{ desc: 'RSA user key (new OpenSSH)', clientKey: fixtureKey('openssh_new_rsa') },
|
|
{ desc: 'DSA user key', clientKey: fixtureKey('id_dsa') },
|
|
{ desc: 'ECDSA user key', clientKey: fixtureKey('id_ecdsa') }
|
|
].forEach((test) => {
|
|
const { desc, clientKey } = test
|
|
const username = 'KeyUser'
|
|
const { server } = setup(desc, {
|
|
client: {
|
|
username,
|
|
privateKeyPath: clientKey.fullPath
|
|
},
|
|
server: { hostKeys: [fixture('ssh_host_rsa_key')] },
|
|
debug
|
|
})
|
|
|
|
server.on(
|
|
'connection',
|
|
mustCall((conn) => {
|
|
let authAttempt = 0
|
|
conn
|
|
.on(
|
|
'authentication',
|
|
mustCallAtLeast((ctx) => {
|
|
assert(ctx.username === username, `Wrong username: ${ctx.username}`)
|
|
switch (++authAttempt) {
|
|
case 1:
|
|
assert(ctx.method === 'none', `Wrong auth method: ${ctx.method}`)
|
|
return ctx.reject()
|
|
case 2:
|
|
case 3:
|
|
if (authAttempt === 3) assert(ctx.signature, 'Missing publickey signature')
|
|
assert(ctx.method === 'publickey', `Wrong auth method: ${ctx.method}`)
|
|
assert(ctx.key.algo === clientKey.key.type, `Wrong key algo: ${ctx.key.algo}`)
|
|
assert.deepStrictEqual(
|
|
clientKey.key.getPublicSSH(),
|
|
ctx.key.data,
|
|
'Public key mismatch'
|
|
)
|
|
break
|
|
default:
|
|
assert(false, 'Unexpected number of auth attempts')
|
|
}
|
|
if (ctx.signature) {
|
|
const result = clientKey.key.verify(ctx.blob, ctx.signature, ctx.hashAlgo)
|
|
assert(result === true, 'Could not verify publickey signature')
|
|
// We should not expect any further auth attempts after we verify a
|
|
// signature
|
|
authAttempt = Infinity
|
|
}
|
|
ctx.accept()
|
|
}, 2)
|
|
)
|
|
.on(
|
|
'ready',
|
|
mustCall(() => {
|
|
conn.on(
|
|
'session',
|
|
mustCall((accept, reject) => {
|
|
accept().on(
|
|
'exec',
|
|
mustCall((accept, reject) => {
|
|
const stream = accept()
|
|
stream.exit(0)
|
|
stream.end()
|
|
})
|
|
)
|
|
})
|
|
)
|
|
})
|
|
)
|
|
})
|
|
)
|
|
})
|
|
|
|
// Different host key types
|
|
;[
|
|
{ desc: 'RSA host key (old OpenSSH)', hostKey: fixture('id_rsa') },
|
|
{ desc: 'RSA host key (new OpenSSH)', hostKey: fixture('openssh_new_rsa') },
|
|
{ desc: 'DSA host key', hostKey: fixture('ssh_host_dsa_key') },
|
|
{ desc: 'ECDSA host key', hostKey: fixture('ssh_host_ecdsa_key') },
|
|
{ desc: 'PPK', hostKey: fixture('id_rsa.ppk') }
|
|
].forEach((test) => {
|
|
const { desc, hostKey } = test
|
|
const clientKey = fixtureKey('openssh_new_rsa')
|
|
const username = 'KeyUser'
|
|
const { server } = setup(desc, {
|
|
client: {
|
|
username,
|
|
privateKeyPath: clientKey.fullPath
|
|
},
|
|
server: { hostKeys: [hostKey] },
|
|
debug
|
|
})
|
|
|
|
server.on(
|
|
'connection',
|
|
mustCall((conn) => {
|
|
let authAttempt = 0
|
|
conn
|
|
.on(
|
|
'authentication',
|
|
mustCallAtLeast((ctx) => {
|
|
assert(ctx.username === username, `Wrong username: ${ctx.username}`)
|
|
switch (++authAttempt) {
|
|
case 1:
|
|
assert(ctx.method === 'none', `Wrong auth method: ${ctx.method}`)
|
|
return ctx.reject()
|
|
case 2:
|
|
case 3:
|
|
if (authAttempt === 3) assert(ctx.signature, 'Missing publickey signature')
|
|
assert(ctx.method === 'publickey', `Wrong auth method: ${ctx.method}`)
|
|
assert(ctx.key.algo === clientKey.key.type, `Wrong key algo: ${ctx.key.algo}`)
|
|
assert.deepStrictEqual(
|
|
clientKey.key.getPublicSSH(),
|
|
ctx.key.data,
|
|
'Public key mismatch'
|
|
)
|
|
break
|
|
default:
|
|
assert(false, 'Unexpected number of auth attempts')
|
|
}
|
|
if (ctx.signature) {
|
|
const result = clientKey.key.verify(ctx.blob, ctx.signature, ctx.hashAlgo)
|
|
assert(result === true, 'Could not verify publickey signature')
|
|
// We should not expect any further auth attempts after we verify a
|
|
// signature
|
|
authAttempt = Infinity
|
|
}
|
|
ctx.accept()
|
|
}, 2)
|
|
)
|
|
.on(
|
|
'ready',
|
|
mustCall(() => {
|
|
conn.on(
|
|
'session',
|
|
mustCall((accept, reject) => {
|
|
accept().on(
|
|
'exec',
|
|
mustCall((accept, reject) => {
|
|
const stream = accept()
|
|
stream.exit(0)
|
|
stream.end()
|
|
})
|
|
)
|
|
})
|
|
)
|
|
})
|
|
)
|
|
})
|
|
)
|
|
})
|
|
|
|
// Various edge cases
|
|
{
|
|
const clientKey = fixtureKey('openssh_new_rsa')
|
|
const username = 'KeyUser'
|
|
const { server } = setup('Server closes stdin too early', {
|
|
client: {
|
|
username,
|
|
privateKeyPath: clientKey.fullPath
|
|
},
|
|
server: { hostKeys: [fixture('ssh_host_rsa_key')] },
|
|
debug
|
|
})
|
|
|
|
server
|
|
.on(
|
|
'_child',
|
|
mustCall((childProc) => {
|
|
childProc.stderr.once(
|
|
'data',
|
|
mustCall((data) => {
|
|
childProc.stdin.end()
|
|
})
|
|
)
|
|
childProc.stdin.write('ping')
|
|
})
|
|
)
|
|
.on(
|
|
'connection',
|
|
mustCall((conn) => {
|
|
let authAttempt = 0
|
|
conn
|
|
.on(
|
|
'authentication',
|
|
mustCallAtLeast((ctx) => {
|
|
assert(ctx.username === username, `Wrong username: ${ctx.username}`)
|
|
switch (++authAttempt) {
|
|
case 1:
|
|
assert(ctx.method === 'none', `Wrong auth method: ${ctx.method}`)
|
|
return ctx.reject()
|
|
case 2:
|
|
case 3:
|
|
if (authAttempt === 3) assert(ctx.signature, 'Missing publickey signature')
|
|
assert(ctx.method === 'publickey', `Wrong auth method: ${ctx.method}`)
|
|
assert(ctx.key.algo === clientKey.key.type, `Wrong key algo: ${ctx.key.algo}`)
|
|
assert.deepStrictEqual(
|
|
clientKey.key.getPublicSSH(),
|
|
ctx.key.data,
|
|
'Public key mismatch'
|
|
)
|
|
break
|
|
default:
|
|
assert(false, 'Unexpected number of auth attempts')
|
|
}
|
|
if (ctx.signature) {
|
|
const result = clientKey.key.verify(ctx.blob, ctx.signature, ctx.hashAlgo)
|
|
assert(result === true, 'Could not verify publickey signature')
|
|
// We should not expect any further auth attempts after we verify a
|
|
// signature
|
|
authAttempt = Infinity
|
|
}
|
|
ctx.accept()
|
|
}, 2)
|
|
)
|
|
.on(
|
|
'ready',
|
|
mustCall(() => {
|
|
conn.on(
|
|
'session',
|
|
mustCall((accept, reject) => {
|
|
accept().on(
|
|
'exec',
|
|
mustCall((accept, reject) => {
|
|
const stream = accept()
|
|
stream.stdin
|
|
.on(
|
|
'data',
|
|
mustCallAtLeast((data) => {
|
|
stream.stdout.write('pong on stdout')
|
|
stream.stderr.write('pong on stderr')
|
|
})
|
|
)
|
|
.on(
|
|
'end',
|
|
mustCall(() => {
|
|
stream.stdout.write('pong on stdout')
|
|
stream.stderr.write('pong on stderr')
|
|
stream.exit(0)
|
|
stream.close()
|
|
})
|
|
)
|
|
})
|
|
)
|
|
})
|
|
)
|
|
})
|
|
)
|
|
})
|
|
)
|
|
}
|
|
{
|
|
const clientKey = fixtureKey('openssh_new_rsa')
|
|
const username = 'KeyUser'
|
|
const { server } = setup('Rekey', {
|
|
client: {
|
|
username,
|
|
privateKeyPath: clientKey.fullPath
|
|
},
|
|
server: { hostKeys: [fixture('ssh_host_rsa_key')] },
|
|
debug
|
|
})
|
|
|
|
server.on(
|
|
'connection',
|
|
mustCall((conn) => {
|
|
let authAttempt = 0
|
|
conn
|
|
.on(
|
|
'authentication',
|
|
mustCallAtLeast((ctx) => {
|
|
assert(ctx.username === username, `Wrong username: ${ctx.username}`)
|
|
switch (++authAttempt) {
|
|
case 1:
|
|
assert(ctx.method === 'none', `Wrong auth method: ${ctx.method}`)
|
|
return ctx.reject()
|
|
case 2:
|
|
case 3:
|
|
if (authAttempt === 3) assert(ctx.signature, 'Missing publickey signature')
|
|
assert(ctx.method === 'publickey', `Wrong auth method: ${ctx.method}`)
|
|
assert(ctx.key.algo === clientKey.key.type, `Wrong key algo: ${ctx.key.algo}`)
|
|
assert.deepStrictEqual(
|
|
clientKey.key.getPublicSSH(),
|
|
ctx.key.data,
|
|
'Public key mismatch'
|
|
)
|
|
break
|
|
default:
|
|
assert(false, 'Unexpected number of auth attempts')
|
|
}
|
|
if (ctx.signature) {
|
|
const result = clientKey.key.verify(ctx.blob, ctx.signature, ctx.hashAlgo)
|
|
assert(result === true, 'Could not verify publickey signature')
|
|
// We should not expect any further auth attempts after we verify a
|
|
// signature
|
|
authAttempt = Infinity
|
|
}
|
|
ctx.accept()
|
|
}, 2)
|
|
)
|
|
.on(
|
|
'ready',
|
|
mustCall(() => {
|
|
conn.on(
|
|
'session',
|
|
mustCall((accept, reject) => {
|
|
const session = accept()
|
|
conn.rekey()
|
|
session.on(
|
|
'exec',
|
|
mustCall((accept, reject) => {
|
|
const stream = accept()
|
|
stream.exit(0)
|
|
stream.end()
|
|
})
|
|
)
|
|
})
|
|
)
|
|
})
|
|
)
|
|
})
|
|
)
|
|
}
|
|
|
|
function setup(title, configs) {
|
|
const {
|
|
client: clientCfg,
|
|
server: serverCfg,
|
|
allReady: allReady_,
|
|
timeout: timeout_,
|
|
debug,
|
|
noForceServerReady
|
|
} = configs
|
|
let clientClose = false
|
|
let serverClose = false
|
|
let serverReady = false
|
|
let client
|
|
const msg = (text) => {
|
|
return `${title}: ${text}`
|
|
}
|
|
|
|
const timeout = typeof timeout_ === 'number' ? timeout_ : CLIENT_TIMEOUT
|
|
|
|
const allReady = typeof allReady_ === 'function' ? allReady_ : undefined
|
|
|
|
if (debug) {
|
|
serverCfg.debug = (...args) => {
|
|
console.log(`[${title}][SERVER]`, ...args)
|
|
}
|
|
}
|
|
|
|
const serverReadyFn = noForceServerReady ? onReady : mustCall(onReady)
|
|
const server = new Server(serverCfg)
|
|
|
|
server
|
|
.on('error', onError)
|
|
.on(
|
|
'connection',
|
|
mustCall((conn) => {
|
|
conn.on('error', onError).on('ready', serverReadyFn)
|
|
server.close()
|
|
})
|
|
)
|
|
.on('close', mustCall(onClose))
|
|
|
|
function onError(err) {
|
|
const which = arguments.length >= 3 ? 'client' : 'server'
|
|
assert(false, msg(`Unexpected ${which} error: ${err}`))
|
|
}
|
|
|
|
function onReady() {
|
|
assert(!serverReady, msg('Received multiple ready events for server'))
|
|
serverReady = true
|
|
allReady && allReady()
|
|
}
|
|
|
|
function onClose() {
|
|
if (arguments.length >= 3) {
|
|
assert(!clientClose, msg('Received multiple close events for client'))
|
|
clientClose = true
|
|
} else {
|
|
assert(!serverClose, msg('Received multiple close events for server'))
|
|
serverClose = true
|
|
}
|
|
}
|
|
|
|
process.nextTick(
|
|
mustCall(() => {
|
|
server.listen(
|
|
0,
|
|
'localhost',
|
|
mustCall(() => {
|
|
const args = [
|
|
'-o',
|
|
'UserKnownHostsFile=/dev/null',
|
|
'-o',
|
|
'StrictHostKeyChecking=no',
|
|
'-o',
|
|
'CheckHostIP=no',
|
|
'-o',
|
|
'ConnectTimeout=3',
|
|
'-o',
|
|
'GlobalKnownHostsFile=/dev/null',
|
|
'-o',
|
|
'GSSAPIAuthentication=no',
|
|
'-o',
|
|
'IdentitiesOnly=yes',
|
|
'-o',
|
|
'BatchMode=yes',
|
|
'-o',
|
|
'VerifyHostKeyDNS=no',
|
|
|
|
'-vvvvvv',
|
|
'-T',
|
|
'-o',
|
|
'KbdInteractiveAuthentication=no',
|
|
'-o',
|
|
'HostbasedAuthentication=no',
|
|
'-o',
|
|
'PasswordAuthentication=no',
|
|
'-o',
|
|
'PubkeyAuthentication=yes',
|
|
'-o',
|
|
'PreferredAuthentications=publickey'
|
|
]
|
|
|
|
if (clientCfg.privateKeyPath) args.push('-o', `IdentityFile=${clientCfg.privateKeyPath}`)
|
|
|
|
if (!/^[0-6]\./.test(opensshVer)) {
|
|
// OpenSSH 7.0+ disables DSS/DSA host (and user) key support by
|
|
// default, so we explicitly enable it here
|
|
args.push('-o', 'HostKeyAlgorithms=+ssh-dss')
|
|
args.push('-o', 'PubkeyAcceptedKeyTypes=+ssh-dss')
|
|
args.push('-o', 'PubkeyAcceptedAlgorithms=+ssh-dss')
|
|
}
|
|
|
|
args.push(
|
|
'-p',
|
|
server.address().port.toString(),
|
|
'-l',
|
|
clientCfg.username,
|
|
'localhost',
|
|
'uptime'
|
|
)
|
|
|
|
client = spawn(opensshPath, args, SPAWN_OPTS)
|
|
server.emit('_child', client)
|
|
|
|
if (debug) {
|
|
readline
|
|
.createInterface({
|
|
input: client.stdout
|
|
})
|
|
.on('line', (line) => {
|
|
console.log(`[${title}][CLIENT][STDOUT]`, line)
|
|
})
|
|
readline
|
|
.createInterface({
|
|
input: client.stderr
|
|
})
|
|
.on('line', (line) => {
|
|
console.error(`[${title}][CLIENT][STDERR]`, line)
|
|
})
|
|
} else {
|
|
client.stdout.resume()
|
|
client.stderr.resume()
|
|
}
|
|
|
|
client
|
|
.on('error', (err) => {
|
|
onError(err, null, null)
|
|
})
|
|
.on('exit', (code) => {
|
|
clearTimeout(client.timer)
|
|
if (code !== 0) return onError(new Error(`Non-zero exit code ${code}`), null, null)
|
|
onClose(null, null, null)
|
|
})
|
|
|
|
client.timer = setTimeout(() => {
|
|
assert(false, msg('Client timeout'))
|
|
}, timeout)
|
|
})
|
|
)
|
|
})
|
|
)
|
|
|
|
return { server }
|
|
}
|