import { execFileSync } from 'node:child_process' 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 TEST_HOST_KEY = '/tmp/testsshd/host_key.pub' // ssh-keygen is what tells us the expected value, so it is part of the // prerequisite too. const prerequisitesPresent = existsSync(TEST_USER_KEY) && existsSync(TEST_HOST_KEY) && (() => { try { execFileSync('ssh-keygen', ['-lf', TEST_HOST_KEY], { stdio: 'ignore' }) return true } catch { return false } })() /** * Ask `ssh-keygen` what the server's fingerprint is, rather than writing it * down here. * * A literal would be tied to whichever key happened to exist when it was * pasted in, and the test key is regenerated whenever `/tmp` is cleared — so * the suite would go red after every reboot for a reason that has nothing to * do with the code. A check that cries wolf is a check people learn to ignore. * * It is also a stronger assertion: the expected value comes from the reference * implementation, not from this project's own fingerprint code. */ function expectedFingerprint(): string | null { try { const output = execFileSync('ssh-keygen', ['-lf', TEST_HOST_KEY], { encoding: 'utf8' }) return /SHA256:[A-Za-z0-9+/]+=*/.exec(output)?.[0] ?? null } catch { return null } } /** * Read fresh at each call rather than captured once. * * A module-level constant would be fine here, but a function makes it obvious * at every call site that this value comes from outside — which is the whole * point of not hardcoding it. */ function EXPECTED_FINGERPRINT(): string { const value = expectedFingerprint() if (!value) throw new Error('could not read the test sshd host key fingerprint') return value } 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) }) })