Connection tree with interleaved folders and hosts, tabbed terminals grouped by host, a pinned local shell, TOFU host key verification, and credentials held by the OS keyring. Terminal bytes travel on a per-session MessagePort with credit-based flow control, so one session running `cat` on a large file cannot stall the others. Transports reconnect on their own with jittered backoff, keeping the scrollback and the tab while replacing only the connection beneath. 156 tests, including integration tests against a real sshd and a migration exercised against a database built in the old shape.
194 lines
6.7 KiB
TypeScript
194 lines
6.7 KiB
TypeScript
import { existsSync } from 'node:fs'
|
|
import { userInfo } from 'node:os'
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import type { Client } from 'ssh2'
|
|
import { dial, type DialResult } from '../src/main/session/dial'
|
|
import { findKnownHost, recordKnownHost } from '../src/main/db/repositories/knownHosts'
|
|
import type { HostRecord } from '../src/main/db/repositories/connections'
|
|
import { createTempDb, type TempDb } from './helpers'
|
|
|
|
/**
|
|
* Integration tests against a real OpenSSH server, not a mock and not ssh2's
|
|
* own server implementation. The trust-on-first-use state machine is the most
|
|
* security-sensitive logic in the app, and testing it against itself would
|
|
* prove only that it is self-consistent.
|
|
*
|
|
* Start the server first:
|
|
*
|
|
* mkdir -p /tmp/testsshd && ssh-keygen -t ed25519 -f /tmp/testsshd/host_key -N '' -q
|
|
* ssh-keygen -t ed25519 -f /tmp/testsshd/user_key -N '' -q
|
|
* cp /tmp/testsshd/user_key.pub /tmp/testsshd/authorized_keys
|
|
* printf 'Port 2222\nListenAddress 127.0.0.1\nHostKey /tmp/testsshd/host_key\n' \
|
|
* 'AuthorizedKeysFile /tmp/testsshd/authorized_keys\nUsePAM no\nStrictModes no\n' \
|
|
* > /tmp/testsshd/sshd_config
|
|
* sshd -f /tmp/testsshd/sshd_config -D -e &
|
|
*
|
|
* The suite skips when that server is absent rather than failing, so the unit
|
|
* tests stay runnable anywhere.
|
|
*/
|
|
const TEST_PORT = 2222
|
|
const TEST_USER_KEY = '/tmp/testsshd/user_key'
|
|
const EXPECTED_FINGERPRINT = 'SHA256:ASmbFHszdZLp5YkSiJnqmGfAhpZKOZwQJnk3rLUo4Xo'
|
|
|
|
const prerequisitesPresent = existsSync(TEST_USER_KEY)
|
|
|
|
let temp: TempDb
|
|
const openClients: Client[] = []
|
|
|
|
beforeEach(() => {
|
|
temp = createTempDb()
|
|
})
|
|
|
|
afterEach(() => {
|
|
for (const client of openClients.splice(0)) {
|
|
try {
|
|
client.end()
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
}
|
|
temp.cleanup()
|
|
})
|
|
|
|
function testHost(overrides: Partial<HostRecord> = {}): HostRecord {
|
|
return {
|
|
id: 1,
|
|
parentId: null,
|
|
label: 'test-server',
|
|
hostname: '127.0.0.1',
|
|
port: TEST_PORT,
|
|
username: userInfo().username,
|
|
authType: 'key',
|
|
keyPath: TEST_USER_KEY,
|
|
color: null,
|
|
sortOrder: 1024,
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
async function attempt(overrides: Partial<HostRecord> = {}, trustFingerprint: string | null = null, password: string | null = null): Promise<DialResult> {
|
|
const result = await dial({
|
|
db: temp.db,
|
|
host: testHost(overrides),
|
|
password,
|
|
passphrase: null,
|
|
trustFingerprint,
|
|
readyTimeoutMs: 8_000,
|
|
})
|
|
if (result.ok) openClients.push(result.client)
|
|
return result
|
|
}
|
|
|
|
describe.runIf(prerequisitesPresent)('dial — trust on first use', () => {
|
|
it('refuses an unrecognised host key rather than connecting', async () => {
|
|
const result = await attempt()
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) return
|
|
expect(result.failure.reason).toBe('unknown-host-key')
|
|
if (result.failure.reason !== 'unknown-host-key') return
|
|
|
|
// The fingerprint shown must be the real one, or the user cannot check it.
|
|
expect(result.failure.fingerprint).toBe(EXPECTED_FINGERPRINT)
|
|
expect(result.failure.keyType).toBe('ssh-ed25519')
|
|
})
|
|
|
|
it('records nothing when the key was not trusted', async () => {
|
|
// A refused connection must leave no trace, or the next attempt would be
|
|
// silently accepted without the user ever seeing the fingerprint.
|
|
await attempt()
|
|
|
|
expect(findKnownHost(temp.db, '127.0.0.1', TEST_PORT, 'ssh-ed25519')).toBeNull()
|
|
})
|
|
|
|
it('connects when the presented fingerprint is trusted', async () => {
|
|
const result = await attempt({}, EXPECTED_FINGERPRINT)
|
|
|
|
expect(result.ok).toBe(true)
|
|
if (result.ok) expect(result.fingerprint).toBe(EXPECTED_FINGERPRINT)
|
|
})
|
|
|
|
it('remembers the key once trusted', async () => {
|
|
await attempt({}, EXPECTED_FINGERPRINT)
|
|
|
|
const known = findKnownHost(temp.db, '127.0.0.1', TEST_PORT, 'ssh-ed25519')
|
|
expect(known?.fingerprint).toBe(EXPECTED_FINGERPRINT)
|
|
})
|
|
|
|
it('connects without a prompt once the key is recorded', async () => {
|
|
await attempt({}, EXPECTED_FINGERPRINT)
|
|
|
|
const second = await attempt()
|
|
|
|
expect(second.ok).toBe(true)
|
|
})
|
|
|
|
it('refuses a trust token that does not match the key presented', async () => {
|
|
// The user accepted one fingerprint and a different key turned up — the
|
|
// exact shape of an interception. It must not be waved through.
|
|
const result = await attempt({}, 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) return
|
|
expect(result.failure.reason).toBe('host-key-mismatch')
|
|
})
|
|
|
|
it('refuses a changed key even though an earlier one was trusted', async () => {
|
|
recordKnownHost(temp.db, '127.0.0.1', TEST_PORT, 'ssh-ed25519', 'SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB')
|
|
|
|
const result = await attempt()
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) return
|
|
expect(result.failure.reason).toBe('host-key-mismatch')
|
|
if (result.failure.reason !== 'host-key-mismatch') return
|
|
expect(result.failure.expected).toBe('SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB')
|
|
expect(result.failure.actual).toBe(EXPECTED_FINGERPRINT)
|
|
})
|
|
|
|
it('reports a readable failure for an unreadable key file', async () => {
|
|
const result = await attempt({ keyPath: '/nonexistent/id_ed25519' })
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) return
|
|
expect(result.failure.reason).toBe('error')
|
|
if (result.failure.reason === 'error') {
|
|
expect(result.failure.message).toMatch(/private key/)
|
|
}
|
|
})
|
|
|
|
it('verifies the host key before attempting authentication', async () => {
|
|
// Ordering matters and is not obvious: an untrusted key stops the attempt
|
|
// before a password is ever offered to the server. Getting this backwards
|
|
// would leak credentials to whatever is on the other end.
|
|
const result = await attempt({ authType: 'password' }, null, 'definitely-not-the-password')
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) return
|
|
expect(result.failure.reason).toBe('unknown-host-key')
|
|
})
|
|
|
|
it('reports auth failure distinctly from a transport error', async () => {
|
|
// The distinction matters: auth failure means "offer a password prompt",
|
|
// a transport error does not. The key has to be trusted first, or this
|
|
// never reaches authentication.
|
|
const result = await attempt(
|
|
{ authType: 'password' },
|
|
EXPECTED_FINGERPRINT,
|
|
'definitely-not-the-password',
|
|
)
|
|
|
|
expect(result.ok).toBe(false)
|
|
if (result.ok) return
|
|
expect(result.failure.reason).toBe('auth-failed')
|
|
})
|
|
})
|
|
|
|
describe.runIf(!prerequisitesPresent)('dial — integration suite skipped', () => {
|
|
it('reports why', () => {
|
|
// Deliberately visible: a silently absent integration suite reads as
|
|
// passing coverage when it is nothing of the sort.
|
|
expect(prerequisitesPresent).toBe(false)
|
|
})
|
|
})
|