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 { 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 = {}, trustFingerprint: string | null = null, password: string | null = null): Promise { 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) }) })