Files
ylhan e1193ed032 Use the desktop's own monospace font in the terminal
Read org.gnome.desktop.interface monospace-font-name at startup rather than
hardcoding a family, so the terminal matches GNOME Terminal exactly and keeps
matching after the system font changes.

Pango reports points and CSS wants pixels, so 13 becomes 17.33px — rendering
it as 13px made the terminal a third too small, which reads as the wrong font
rather than the wrong size. The size is the trailing number, since families
may contain digits. Families are always quoted, because an unquoted
"Noto Sans Mono" is a three-family stack to CSS.

Also fixes tests/dial.test.ts, which hardcoded the test server's fingerprint
and so went red after every /tmp cleanup. It now asks ssh-keygen, which is a
stronger assertion anyway: the expected value comes from the reference
implementation rather than from this project's own fingerprint code.
2026-09-21 23:02:20 +08:00

241 lines
8.3 KiB
TypeScript

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> = {}): 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)
})
})