diff --git a/src/main/ssh/ssh-config-host-picker.test.ts b/src/main/ssh/ssh-config-host-picker.test.ts index 4d7666fa457..783eef679ca 100644 --- a/src/main/ssh/ssh-config-host-picker.test.ts +++ b/src/main/ssh/ssh-config-host-picker.test.ts @@ -29,6 +29,11 @@ function resolved(overrides: Partial = {}): SshResolvedConfig proxyUseFdpass: false, controlMaster: 'no', controlPersist: 'no', + userKnownHostsFiles: [], + globalKnownHostsFiles: [], + strictHostKeyChecking: 'ask', + hashKnownHosts: false, + updateHostKeys: 'no', ...overrides } } diff --git a/src/main/ssh/ssh-connection-utils.test.ts b/src/main/ssh/ssh-connection-utils.test.ts index 72dd7991d71..308acc7c59f 100644 --- a/src/main/ssh/ssh-connection-utils.test.ts +++ b/src/main/ssh/ssh-connection-utils.test.ts @@ -385,6 +385,11 @@ function makeResolved(overrides?: Partial): SshResolvedConfig proxyUseFdpass: false, controlMaster: 'no', controlPersist: 'no', + userKnownHostsFiles: [], + globalKnownHostsFiles: [], + strictHostKeyChecking: 'ask', + hashKnownHosts: false, + updateHostKeys: 'no', ...overrides } } diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index f53190621ab..fe8aaf66d08 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -227,6 +227,11 @@ function createResolvedConfig(overrides?: Partial): SshResolv proxyUseFdpass: true, controlMaster: 'no', controlPersist: 'no', + userKnownHostsFiles: [], + globalKnownHostsFiles: [], + strictHostKeyChecking: 'ask', + hashKnownHosts: false, + updateHostKeys: 'no', ...overrides } } diff --git a/src/main/ssh/ssh-g-config-resolution.ts b/src/main/ssh/ssh-g-config-resolution.ts index e329806ef3c..b4257d24406 100644 --- a/src/main/ssh/ssh-g-config-resolution.ts +++ b/src/main/ssh/ssh-g-config-resolution.ts @@ -23,6 +23,13 @@ export type SshResolvedConfig = { controlMaster: string controlPath?: string controlPersist: string + /** May hold the literal `none`, which OpenSSH prints for an explicit opt-out. */ + userKnownHostsFiles: string[] + globalKnownHostsFiles: string[] + strictHostKeyChecking: string + hostKeyAlias?: string + hashKnownHosts: boolean + updateHostKeys: string } const SSH_G_TIMEOUT_MS = 5000 @@ -118,6 +125,41 @@ export function parseSshGOutput(stdout: string): SshResolvedConfig { return buildSshResolvedConfig(map, identityFiles) } +/** + * `userknownhostsfile` / `globalknownhostsfile` arrive as one space-separated line; a path + * containing spaces is double-quoted. Older OpenSSH leaves `~` unexpanded, newer expands it. + */ +function parseKnownHostsFileList(value: string | undefined): string[] { + if (!value) { + return [] + } + const paths: string[] = [] + let current = '' + let inQuotes = false + let hasToken = false + for (const char of value) { + if (char === '"') { + inQuotes = !inQuotes + hasToken = true + continue + } + if (!inQuotes && /\s/.test(char)) { + if (hasToken) { + paths.push(current) + current = '' + hasToken = false + } + continue + } + current += char + hasToken = true + } + if (hasToken) { + paths.push(current) + } + return paths.map(resolveSshConfigHomePath) +} + function buildSshResolvedConfig( map: Map, identityFiles: string[] @@ -150,6 +192,13 @@ function buildSshResolvedConfig( proxyJump, controlMaster: map.get('controlmaster') ?? 'no', controlPath, - controlPersist: map.get('controlpersist') ?? 'no' + controlPersist: map.get('controlpersist') ?? 'no', + userKnownHostsFiles: parseKnownHostsFileList(map.get('userknownhostsfile')), + globalKnownHostsFiles: parseKnownHostsFileList(map.get('globalknownhostsfile')), + // OpenSSH's own default, so an unreadable line never resolves laxer than ssh would. + strictHostKeyChecking: map.get('stricthostkeychecking') ?? 'ask', + hostKeyAlias: map.get('hostkeyalias') || undefined, + hashKnownHosts: map.get('hashknownhosts') === 'yes', + updateHostKeys: map.get('updatehostkeys') ?? 'no' } } diff --git a/src/main/ssh/ssh-host-key-store.test.ts b/src/main/ssh/ssh-host-key-store.test.ts new file mode 100644 index 00000000000..cefc449f06f --- /dev/null +++ b/src/main/ssh/ssh-host-key-store.test.ts @@ -0,0 +1,229 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + forgetHostKey, + getSshHostKeyStoreFile, + isTrusted, + loadTrustedHostKeys, + trustHostKey +} from './ssh-host-key-store' + +/** A blob shaped like a real host key: length-prefixed algorithm name, then payload. */ +function hostKey(keyType: string, seed: string): Buffer { + const name = Buffer.from(keyType, 'utf8') + const length = Buffer.alloc(4) + length.writeUInt32BE(name.length, 0) + return Buffer.concat([length, name, Buffer.from(seed.padEnd(32, '.'), 'utf8')]) +} + +const ED25519_A = hostKey('ssh-ed25519', 'key-a') +const ED25519_B = hostKey('ssh-ed25519', 'key-b') +const RSA_A = hostKey('ssh-rsa', 'rsa-a') + +function query(overrides: Partial[0]> = {}) { + return { + host: 'build-01', + port: 22, + keyType: 'ssh-ed25519', + key: ED25519_A, + ...overrides + } +} + +let directory: string +let storeFile: string + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-host-key-store-')) + storeFile = getSshHostKeyStoreFile(join(directory, 'orca-data.json')) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rm(directory, { recursive: true, force: true }) +}) + +describe('ssh host key store', () => { + it('places the store beside the profile data file, not inside it', () => { + expect(getSshHostKeyStoreFile(join('/profiles', 'p1', 'orca-data.json'))).toBe( + join('/profiles', 'p1', 'ssh-host-keys.json') + ) + }) + + it('recognises a key it was told to trust', async () => { + expect(await isTrusted(query(), storeFile)).toBe('unknown') + + const record = await trustHostKey(query(), storeFile) + + expect(record.fingerprint).toMatch(/^SHA256:[A-Za-z0-9+/]+$/) + expect(await isTrusted(query(), storeFile)).toBe('match') + }) + + it('reports a different key for the same host, port and type as a mismatch', async () => { + await trustHostKey(query(), storeFile) + + expect(await isTrusted(query({ key: ED25519_B }), storeFile)).toBe('mismatch') + }) + + it('supersedes the stored key when the same triple is trusted again', async () => { + await trustHostKey(query(), storeFile) + await trustHostKey(query({ key: ED25519_B }), storeFile) + + expect(await loadTrustedHostKeys(storeFile)).toHaveLength(1) + expect(await isTrusted(query({ key: ED25519_B }), storeFile)).toBe('match') + expect(await isTrusted(query(), storeFile)).toBe('mismatch') + }) + + it('keeps a second key type for the same host alongside the first', async () => { + await trustHostKey(query(), storeFile) + await trustHostKey(query({ keyType: 'ssh-rsa', key: RSA_A }), storeFile) + + expect(await loadTrustedHostKeys(storeFile)).toHaveLength(2) + expect(await isTrusted(query(), storeFile)).toBe('match') + expect(await isTrusted(query({ keyType: 'ssh-rsa', key: RSA_A }), storeFile)).toBe('match') + }) + + it('treats an unrecorded key type for a known host as suspicious, not first contact', async () => { + await trustHostKey(query({ keyType: 'ssh-rsa', key: RSA_A }), storeFile) + + expect(await isTrusted(query(), storeFile)).toBe('unknown-type-known-host') + }) + + it('scopes trust to the endpoint, so a second target naming the same host is already trusted', async () => { + await trustHostKey(query({ host: 'build-01' }), storeFile) + + // A different Orca target, same machine — no target id is recorded anywhere. + expect(await isTrusted(query({ host: 'BUILD-01' }), storeFile)).toBe('match') + const [record] = await loadTrustedHostKeys(storeFile) + expect(Object.keys(record ?? {})).toEqual([ + 'host', + 'port', + 'keyType', + 'key', + 'fingerprint', + 'acceptedAt' + ]) + }) + + it('does not carry trust across ports', async () => { + await trustHostKey(query(), storeFile) + + expect(await isTrusted(query({ port: 2222 }), storeFile)).toBe('unknown') + }) + + it('trusts nothing when the file is missing', async () => { + expect(await loadTrustedHostKeys(storeFile)).toEqual([]) + expect(await isTrusted(query(), storeFile)).toBe('unknown') + }) + + it('trusts nothing and does not throw when the file is corrupt', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + await writeFile(storeFile, '{"version":1,"hostKeys":[{"host":"build-01"', 'utf-8') + + await expect(loadTrustedHostKeys(storeFile)).resolves.toEqual([]) + await expect(isTrusted(query(), storeFile)).resolves.toBe('unknown') + expect(warn).toHaveBeenCalled() + }) + + it('trusts nothing when the file parses but is not a store', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await writeFile(storeFile, '"everything"', 'utf-8') + + await expect(isTrusted(query(), storeFile)).resolves.toBe('unknown') + }) + + it('drops a record whose key does not carry the type it claims', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await trustHostKey(query(), storeFile) + const stored = JSON.parse(await readFile(storeFile, 'utf-8')) as { + hostKeys: { keyType: string }[] + } + stored.hostKeys[0]!.keyType = 'ssh-rsa' + await writeFile(storeFile, JSON.stringify(stored), 'utf-8') + + expect(await loadTrustedHostKeys(storeFile)).toEqual([]) + expect(await isTrusted(query({ keyType: 'ssh-rsa' }), storeFile)).toBe('unknown') + }) + + it('drops a record whose fingerprint disagrees with its key', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await trustHostKey(query(), storeFile) + const stored = JSON.parse(await readFile(storeFile, 'utf-8')) as { + hostKeys: { fingerprint: string }[] + } + stored.hostKeys[0]!.fingerprint = 'SHA256:not-the-key' + await writeFile(storeFile, JSON.stringify(stored), 'utf-8') + + expect(await isTrusted(query(), storeFile)).toBe('unknown') + }) + + it('forgets one key type without disturbing the others', async () => { + await trustHostKey(query(), storeFile) + await trustHostKey(query({ keyType: 'ssh-rsa', key: RSA_A }), storeFile) + await trustHostKey(query({ host: 'other-host' }), storeFile) + + const removed = await forgetHostKey( + { host: 'build-01', port: 22, keyType: 'ssh-ed25519' }, + storeFile + ) + + expect(removed).toBe(1) + expect(await isTrusted(query(), storeFile)).toBe('unknown-type-known-host') + expect(await isTrusted(query({ keyType: 'ssh-rsa', key: RSA_A }), storeFile)).toBe('match') + expect(await isTrusted(query({ host: 'other-host' }), storeFile)).toBe('match') + }) + + it('forgets every key type for an endpoint when no type is given', async () => { + await trustHostKey(query(), storeFile) + await trustHostKey(query({ keyType: 'ssh-rsa', key: RSA_A }), storeFile) + await trustHostKey(query({ port: 2222 }), storeFile) + + expect(await forgetHostKey({ host: 'build-01', port: 22 }, storeFile)).toBe(2) + expect(await isTrusted(query(), storeFile)).toBe('unknown') + expect(await isTrusted(query({ port: 2222 }), storeFile)).toBe('match') + }) + + it('reports nothing removed when there is no such record', async () => { + await trustHostKey(query(), storeFile) + + expect(await forgetHostKey({ host: 'absent', port: 22 }, storeFile)).toBe(0) + expect(await isTrusted(query(), storeFile)).toBe('match') + }) + + it('never publishes a half-written store: a torn payload trusts nothing', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await trustHostKey(query(), storeFile) + const whole = await readFile(storeFile, 'utf-8') + // A crash mid-write, had it landed on the final path: valid JSON prefix, truncated. + await writeFile(storeFile, whole.slice(0, Math.floor(whole.length / 2)), 'utf-8') + + await expect(isTrusted(query(), storeFile)).resolves.toBe('unknown') + await expect(loadTrustedHostKeys(storeFile)).resolves.toEqual([]) + }) + + it('leaves no temp file behind and keeps the store parseable after a write', async () => { + await trustHostKey(query(), storeFile) + + expect(JSON.parse(await readFile(storeFile, 'utf-8'))).toMatchObject({ + version: 1 + }) + const { readdir } = await import('node:fs/promises') + expect((await readdir(directory)).filter((name) => name.endsWith('.tmp'))).toEqual([]) + }) + + it('keeps both accepts when two hosts are trusted concurrently', async () => { + await Promise.all([ + trustHostKey(query({ host: 'host-a' }), storeFile), + trustHostKey(query({ host: 'host-b', key: ED25519_B }), storeFile) + ]) + + expect(await isTrusted(query({ host: 'host-a' }), storeFile)).toBe('match') + expect(await isTrusted(query({ host: 'host-b', key: ED25519_B }), storeFile)).toBe('match') + }) + + it('refuses to answer before the store is bound to a profile', async () => { + await expect(isTrusted(query())).rejects.toThrow(/initSshHostKeyStoreFile/) + }) +}) diff --git a/src/main/ssh/ssh-host-key-store.ts b/src/main/ssh/ssh-host-key-store.ts new file mode 100644 index 00000000000..6959d2be94c --- /dev/null +++ b/src/main/ssh/ssh-host-key-store.ts @@ -0,0 +1,288 @@ +/** + * Orca's own record of accepted SSH host keys, consulted alongside the user's `known_hosts`. + * + * We read `known_hosts` but never write it (D1), so accepted keys land here instead. See + * docs/reference/ssh-host-key-verification.md — D1, D5 and D8 are the load-bearing decisions. + */ +import { createHash } from 'node:crypto' +import { mkdir, readFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { withSidecarSnapshotQueue, writeSidecarSnapshot } from '../sidecar-snapshot-file' +import { + formatHostKeyFingerprint, + readHostKeyType, + type KnownHostsOutcome, + type KnownHostsQuery +} from './ssh-known-hosts' + +const STORE_FILE_NAME = 'ssh-host-keys.json' +const STORE_VERSION = 1 +const MAX_PORT = 65535 + +export type TrustedHostKeyRecord = { + /** Lower-cased `HostKeyAlias` or resolved hostname — never the Orca target label (D2 lookup key). */ + host: string + port: number + keyType: string + /** The presented blob, base64. Matching compares these bytes; the fingerprint is for display. */ + key: string + fingerprint: string + acceptedAt: string +} + +/** + * Our store holds neither CA nor revoked entries, so it can only reach four of the six outcomes — + * but they are the same four, so a caller can union this with `matchKnownHosts` untranslated. + */ +export type HostKeyStoreOutcome = Extract< + KnownHostsOutcome, + 'match' | 'mismatch' | 'unknown-type-known-host' | 'unknown' +> + +type HostKeyStoreFile = { + version: number + hostKeys: TrustedHostKeyRecord[] +} + +/** Beside the profile's data file, like the GitHub cache and scrollback snapshots. */ +export function getSshHostKeyStoreFile(dataFile: string): string { + return join(dirname(dataFile), STORE_FILE_NAME) +} + +let configuredStoreFile: string | null = null + +/** Bind the store to the active profile once at startup, so connect paths need not thread `dataFile`. */ +export function initSshHostKeyStoreFile(dataFile: string): void { + configuredStoreFile = getSshHostKeyStoreFile(dataFile) +} + +/** + * Why throw rather than default to empty: an unconfigured store is a wiring bug, and answering + * "nothing trusted" would quietly turn every host into first contact. The verifier wraps its work + * and denies on throw (D7), so failing loudly here still fails closed. + */ +function requireStoreFile(file?: string): string { + const resolved = file ?? configuredStoreFile + if (!resolved) { + throw new Error('SSH host key store used before initSshHostKeyStoreFile()') + } + return resolved +} + +function normalizeHost(host: string): string { + return host.trim().toLowerCase() +} + +function isValidPort(port: unknown): port is number { + return typeof port === 'number' && Number.isInteger(port) && port > 0 && port <= MAX_PORT +} + +function decodeStoredKey(record: TrustedHostKeyRecord): Buffer | undefined { + // Buffer.from never throws on bad base64, it silently truncates — so re-derive and compare. + const key = Buffer.from(record.key, 'base64') + if ( + key.length === 0 || + key.toString('base64').replace(/=+$/, '') !== record.key.replace(/=+$/, '') + ) { + return undefined + } + // The blob's own algorithm header must agree with the record's type field, or a tampered or + // corrupted record could claim a type it does not carry and satisfy a lookup for it. + return readHostKeyType(key) === record.keyType ? key : undefined +} + +function fingerprintOf(key: Buffer): string { + return formatHostKeyFingerprint(createHash('sha256').update(key).digest('base64')) +} + +function validateRecord(candidate: unknown): TrustedHostKeyRecord | undefined { + if (!candidate || typeof candidate !== 'object') { + return undefined + } + const { host, port, keyType, key, fingerprint, acceptedAt } = candidate as Record + if ( + typeof host !== 'string' || + host.length === 0 || + !isValidPort(port) || + typeof keyType !== 'string' || + keyType.length === 0 || + typeof key !== 'string' || + typeof fingerprint !== 'string' || + typeof acceptedAt !== 'string' + ) { + return undefined + } + const record: TrustedHostKeyRecord = { + host: normalizeHost(host), + port, + keyType, + key, + fingerprint, + acceptedAt + } + const decoded = decodeStoredKey(record) + // A fingerprint that disagrees with its key means the record was corrupted or hand-edited; D5 + // shows this fingerprint to the user, so a record we cannot vouch for is dropped rather than shown. + return decoded && fingerprintOf(decoded) === fingerprint ? record : undefined +} + +/** + * Every trusted record, or an empty list when the file is missing or unreadable. + * + * Never throws and never fails open: a corrupt file degrades to "nothing trusted", which costs a + * first-contact prompt, where the opposite mistake would accept anything. + */ +export async function loadTrustedHostKeys(file?: string): Promise { + const storeFile = requireStoreFile(file) + let contents: string + try { + contents = await readFile(storeFile, 'utf-8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn(`[ssh] Could not read the host key store at ${storeFile}:`, error) + } + return [] + } + + let parsed: unknown + try { + parsed = JSON.parse(contents) + } catch { + console.warn(`[ssh] Host key store at ${storeFile} is not valid JSON; treating it as empty`) + return [] + } + + const hostKeys = (parsed as Partial | null)?.hostKeys + if (!Array.isArray(hostKeys)) { + console.warn(`[ssh] Host key store at ${storeFile} has no host key list; treating it as empty`) + return [] + } + + const records: TrustedHostKeyRecord[] = [] + let dropped = 0 + for (const candidate of hostKeys) { + const record = validateRecord(candidate) + if (record) { + records.push(record) + } else { + dropped += 1 + } + } + if (dropped > 0) { + console.warn( + `[ssh] Ignored ${dropped} unusable record(s) in the host key store at ${storeFile}` + ) + } + return records +} + +/** + * Whether we have previously accepted this key for this endpoint. + * + * Scoped to host + port + key type (D8), and exactly — unlike `known_hosts` there is no bare-host + * fallback pass, because we only ever record the endpoint we actually connected to. + */ +export async function isTrusted( + query: KnownHostsQuery, + file?: string +): Promise { + const host = normalizeHost(query.host) + let sawSameType = false + let sawOtherType = false + + for (const record of await loadTrustedHostKeys(file)) { + if (record.host !== host || record.port !== query.port) { + continue + } + if (record.keyType !== query.keyType) { + sawOtherType = true + continue + } + if (decodeStoredKey(record)?.equals(query.key)) { + return 'match' + } + sawSameType = true + } + + if (sawSameType) { + return 'mismatch' + } + // We hold a key for this endpoint, just not of the presented type. Never a first-contact result: + // an attacker who cannot forge the type on file would otherwise present another for a soft outcome. + return sawOtherType ? 'unknown-type-known-host' : 'unknown' +} + +/** + * Record an accepted key, superseding any earlier key for the same host + port + type. + * + * Takes the presented blob rather than a pre-built record so the stored base64 and fingerprint + * cannot disagree with each other. + */ +export async function trustHostKey( + query: KnownHostsQuery, + file?: string +): Promise { + const storeFile = requireStoreFile(file) + const record: TrustedHostKeyRecord = { + host: normalizeHost(query.host), + port: query.port, + keyType: query.keyType, + key: query.key.toString('base64'), + fingerprint: fingerprintOf(query.key), + acceptedAt: new Date().toISOString() + } + // Serialized: startup restore connects to every previously-active target in parallel, so two + // first-contact accepts can otherwise read the same snapshot and one overwrites the other. + await withSidecarSnapshotQueue(storeFile, async () => { + const kept = (await loadTrustedHostKeys(storeFile)).filter( + (existing) => + existing.host !== record.host || + existing.port !== record.port || + existing.keyType !== record.keyType + ) + await persist(storeFile, [...kept, record]) + }) + console.warn( + `[ssh] Trusted host key for ${record.host}:${record.port} (${record.keyType} ${record.fingerprint})` + ) + return record +} + +/** + * Drop trust for an endpoint — the D5 recovery path, which is the only cure for a rotated key + * because we never learn one from `UpdateHostKeys`. Omit `keyType` to forget every type. + * Returns how many records were removed, so the settings surface can say whether it did anything. + */ +export async function forgetHostKey( + target: { host: string; port: number; keyType?: string }, + file?: string +): Promise { + const storeFile = requireStoreFile(file) + const host = normalizeHost(target.host) + return withSidecarSnapshotQueue(storeFile, async () => { + const records = await loadTrustedHostKeys(storeFile) + const kept = records.filter( + (record) => + record.host !== host || + record.port !== target.port || + (target.keyType !== undefined && record.keyType !== target.keyType) + ) + const removed = records.length - kept.length + if (removed > 0) { + await persist(storeFile, kept) + console.warn( + `[ssh] Forgot ${removed} stored host key(s) for ${host}:${target.port}${target.keyType ? ` (${target.keyType})` : ''}` + ) + } + return removed + }) +} + +/** Temp file + fsync + rename, so a crash mid-write can never publish a half-written trust list. */ +async function persist(storeFile: string, hostKeys: TrustedHostKeyRecord[]): Promise { + await mkdir(dirname(storeFile), { recursive: true }).catch(() => {}) + await writeSidecarSnapshot(storeFile, { + version: STORE_VERSION, + hostKeys + } satisfies HostKeyStoreFile) +} diff --git a/src/main/ssh/ssh-host-key-verifier.test.ts b/src/main/ssh/ssh-host-key-verifier.test.ts new file mode 100644 index 00000000000..2ceafda1aec --- /dev/null +++ b/src/main/ssh/ssh-host-key-verifier.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it, vi } from 'vitest' +import { parseKnownHosts } from './ssh-known-hosts' +import { + createHostKeyVerifier, + hostKeyFingerprintOf, + orderServerHostKeyAlgorithms, + type HostKeyVerifierDeps +} from './ssh-host-key-verifier' + +const ED_A = 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq' +const ED_B = 'AAAAC3NzaC1lZDI1NTE5AAAAILu7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7' +const RSA_A = + 'AAAAB3NzaC1yc2EAAABAzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzA==' + +const blob = (base64: string): Buffer => Buffer.from(base64, 'base64') + +function deps(overrides: Partial = {}): HostKeyVerifierDeps { + return { + host: 'example.com', + port: 22, + displayHost: 'example.com', + strictHostKeyChecking: 'ask', + isEphemeralRuntimeTarget: false, + siteConfigSuppressed: false, + entries: [], + isTrusted: () => 'unknown', + rememberHostKey: vi.fn(), + ...overrides + } +} + +/** Runs the verifier synchronously and reports both the decision and what it returned. */ +function run( + overrides: Partial, + key = ED_A +): { accepted: boolean | undefined; returned: unknown } { + let accepted: boolean | undefined + const verifier = createHostKeyVerifier(deps(overrides)) + const returned = verifier(blob(key), (ok) => { + accepted = ok + }) + return { accepted, returned } +} + +describe('the ssh2 host key verifier', () => { + // The regression that would silently restore accept-everything: ssh2 does + // `const ret = verifier(key, verify); if (ret !== undefined) verify(ret)`, so any non-undefined + // return — notably the Promise from an `async` function — accepts before the callback decides. + it('returns nothing, so ssh2 waits for the callback', () => { + expect(run({}).returned).toBeUndefined() + }) + + it('accepts a key the user already has in known_hosts', () => { + const entries = parseKnownHosts(`example.com ssh-ed25519 ${ED_A}`) + expect(run({ entries }).accepted).toBe(true) + }) + + it('accepts a key our own store already holds', () => { + expect(run({ isTrusted: () => 'match' }).accepted).toBe(true) + }) + + it('rejects a changed key', () => { + const entries = parseKnownHosts(`example.com ssh-ed25519 ${ED_B}`) + expect(run({ entries }).accepted).toBe(false) + }) + + it('rejects a revoked key', () => { + const entries = parseKnownHosts(`@revoked example.com ssh-ed25519 ${ED_A}`) + expect(run({ entries }).accepted).toBe(false) + }) + + it('rejects a key whose own header cannot be read', () => { + let accepted: boolean | undefined + createHostKeyVerifier(deps())(Buffer.alloc(2), (ok) => { + accepted = ok + }) + expect(accepted).toBe(false) + }) + + // ssh2 may not catch a throw from inside the verifier, which would hang the handshake instead of + // failing it. + it('denies rather than throwing when a dependency fails', () => { + const { accepted, returned } = run({ + isTrusted: () => { + throw new Error('store unreadable') + } + }) + expect(accepted).toBe(false) + expect(returned).toBeUndefined() + }) + + describe('remembering', () => { + it('records a first-contact key', () => { + const rememberHostKey = vi.fn() + run({ rememberHostKey }) + expect(rememberHostKey).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'example.com', + port: 22, + keyType: 'ssh-ed25519', + fingerprint: hostKeyFingerprintOf(blob(ED_A)) + }) + ) + }) + + it.each([ + ['a key we already know', { entries: parseKnownHosts(`example.com ssh-ed25519 ${ED_A}`) }], + ['a rejected key', { entries: parseKnownHosts(`example.com ssh-ed25519 ${ED_B}`) }], + ['an ephemeral runtime target', { isEphemeralRuntimeTarget: true }], + ['a lax StrictHostKeyChecking', { strictHostKeyChecking: 'no' }] + ])('does not record %s', (_label, overrides) => { + const rememberHostKey = vi.fn() + run({ ...overrides, rememberHostKey }) + expect(rememberHostKey).not.toHaveBeenCalled() + }) + }) + + it('reports every decision for audit', () => { + const onDecision = vi.fn() + run({ onDecision }) + expect(onDecision).toHaveBeenCalledWith( + expect.objectContaining({ outcome: 'unknown', keyType: 'ssh-ed25519' }) + ) + }) +}) + +describe('host key algorithm ordering', () => { + const supported = ['ssh-ed25519', 'rsa-sha2-512', 'ssh-rsa', 'ecdsa-sha2-nistp256'] + + // Without this, type-scoped matching is a downgrade: an attacker who cannot forge the key on + // file just presents another type and turns a hard failure into first contact. + it('leads with the types already known for the host', () => { + const entries = parseKnownHosts(`example.com ssh-rsa ${RSA_A}`) + const ordered = orderServerHostKeyAlgorithms(entries, 'example.com', 22, supported) + expect(ordered?.[0]).toBe('ssh-rsa') + }) + + it('keeps every supported algorithm, only reordered', () => { + const entries = parseKnownHosts(`example.com ssh-rsa ${RSA_A}`) + const ordered = orderServerHostKeyAlgorithms(entries, 'example.com', 22, supported) + expect([...(ordered ?? [])].sort()).toEqual([...supported].sort()) + }) + + it('leaves the defaults alone for a host we know nothing about', () => { + const entries = parseKnownHosts(`other.com ssh-rsa ${RSA_A}`) + expect(orderServerHostKeyAlgorithms(entries, 'example.com', 22, supported)).toBeUndefined() + }) + + it('ignores a revoked entry when choosing what to lead with', () => { + const entries = parseKnownHosts(`@revoked example.com ssh-rsa ${RSA_A}`) + expect(orderServerHostKeyAlgorithms(entries, 'example.com', 22, supported)).toBeUndefined() + }) + + it('does not propose a type the transport does not support', () => { + const entries = parseKnownHosts(`example.com ssh-ed25519 ${ED_A}`) + const ordered = orderServerHostKeyAlgorithms(entries, 'example.com', 22, ['rsa-sha2-512']) + expect(ordered).toBeUndefined() + }) +}) diff --git a/src/main/ssh/ssh-host-key-verifier.ts b/src/main/ssh/ssh-host-key-verifier.ts new file mode 100644 index 00000000000..64b910178fe --- /dev/null +++ b/src/main/ssh/ssh-host-key-verifier.ts @@ -0,0 +1,152 @@ +/** + * Builds the ssh2 `hostVerifier` and the host-key algorithm order that makes it safe. + * + * Separated from `SshConnection` so the decision path can be tested without a handshake, and so the + * two halves that must ship together — type-scoped matching and algorithm ordering — live in one + * file where the dependency is visible. + * + * See docs/reference/ssh-host-key-verification.md. + */ +import { createHash } from 'node:crypto' +import { + formatHostKeyFingerprint, + matchKnownHosts, + readHostKeyType, + type KnownHostsEntry +} from './ssh-known-hosts' +import { decideHostKey, type HostKeyDecision } from './ssh-host-key-decision' + +export type TrustedHostKeyLookup = (query: { + host: string + port: number + keyType: string + key: Buffer +}) => 'match' | 'mismatch' | 'unknown' + +export type HostKeyVerifierDeps = { + host: string + port: number + displayHost: string + strictHostKeyChecking: string + isEphemeralRuntimeTarget: boolean + siteConfigSuppressed: boolean + /** Already unioned across every known_hosts file. */ + entries: readonly KnownHostsEntry[] + isTrusted: TrustedHostKeyLookup + rememberHostKey: (record: { + host: string + port: number + keyType: string + key: Buffer + fingerprint: string + }) => void + /** Called on every decision so accepts and rejections are auditable. */ + onDecision?: (decision: HostKeyDecision & { fingerprint: string; keyType: string }) => void +} + +export function hostKeyFingerprintOf(key: Buffer): string { + return formatHostKeyFingerprint(createHash('sha256').update(key).digest('base64')) +} + +/** + * Host-key algorithms to propose, ordered so the types we already hold for this host come first. + * + * This is what makes type-scoped matching safe rather than a downgrade. RFC 4253 gives the client's + * order priority, so leading with the known types denies a server the choice of presenting some + * other type to convert a hard failure into first contact. Without this, an attacker who cannot + * forge the key on file simply offers a different algorithm. + * + * Returns undefined when we know nothing for the host, leaving ssh2's defaults alone. + */ +export function orderServerHostKeyAlgorithms( + entries: readonly KnownHostsEntry[], + host: string, + port: number, + supported: readonly string[] +): string[] | undefined { + const known = new Set() + for (const entry of entries) { + if (entry.marker === 'revoked') { + continue + } + // Reuse the matcher's own host logic rather than re-implementing pattern/hash matching here. + const outcome = matchKnownHosts([entry], { + host, + port, + keyType: entry.keyType, + key: entry.key + }) + if (outcome === 'match') { + known.add(entry.keyType) + } + } + if (known.size === 0) { + return undefined + } + const preferred = supported.filter((algorithm) => known.has(algorithm)) + if (preferred.length === 0) { + return undefined + } + return [...preferred, ...supported.filter((algorithm) => !known.has(algorithm))] +} + +export type VerifyCallback = (accept: boolean) => void + +/** + * The ssh2 `hostVerifier`. + * + * MUST be a plain function that returns `undefined`. ssh2 does + * `const ret = hostVerifier(key, verify); if (ret !== undefined) verify(ret)` — so an `async` + * function returns a Promise, which is neither undefined nor falsy, and ssh2 accepts the key + * immediately while ignoring whatever the callback later decides. Making this async would silently + * restore the accept-everything behaviour this module exists to remove. + */ +export function createHostKeyVerifier( + deps: HostKeyVerifierDeps +): (key: Buffer, verify: VerifyCallback) => undefined { + return (key, verify) => { + try { + const keyType = readHostKeyType(key) + if (!keyType) { + // A key whose own header we cannot read is not something to reason about further. + verify(false) + return undefined + } + const fingerprint = hostKeyFingerprintOf(key) + const decision = decideHostKey({ + knownHostsOutcome: matchKnownHosts(deps.entries, { + host: deps.host, + port: deps.port, + keyType, + key + }), + storeOutcome: deps.isTrusted({ host: deps.host, port: deps.port, keyType, key }), + strictHostKeyChecking: deps.strictHostKeyChecking, + isEphemeralRuntimeTarget: deps.isEphemeralRuntimeTarget, + siteConfigSuppressed: deps.siteConfigSuppressed, + displayHost: deps.displayHost + }) + + deps.onDecision?.({ ...decision, fingerprint, keyType }) + + if (decision.action === 'accept-and-remember') { + deps.rememberHostKey({ + host: deps.host, + port: deps.port, + keyType, + key, + fingerprint + }) + } + // `prompt` is unreachable in this phase; treating it as a denial keeps the fail-closed + // property if it ever becomes reachable before the dialog exists. + verify(decision.action === 'accept' || decision.action === 'accept-and-remember') + } catch { + // ssh2 may not catch a throw from inside the verifier, which would leave the handshake + // hanging rather than failing. Denying is the only safe outcome for an error we cannot + // interpret. + verify(false) + } + return undefined + } +} diff --git a/src/main/ssh/ssh-known-hosts-source.test.ts b/src/main/ssh/ssh-known-hosts-source.test.ts new file mode 100644 index 00000000000..5c3335af852 --- /dev/null +++ b/src/main/ssh/ssh-known-hosts-source.test.ts @@ -0,0 +1,238 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { parseSshGOutput, type SshResolvedConfig } from './ssh-g-config-resolution' +import { matchKnownHosts, readHostKeyType, type KnownHostsEntry } from './ssh-known-hosts' +import { + defaultKnownHostsFiles, + loadKnownHostsEntries, + resolveKnownHostsFiles, + resolveKnownHostsLookupHost +} from './ssh-known-hosts-source' + +const ED_A = 'AAAAC3NzaC1lZDI1NTE5AAAAIKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq' +const ED_B = 'AAAAC3NzaC1lZDI1NTE5AAAAILu7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7u7' +const ED_C = 'AAAAC3NzaC1lZDI1NTE5AAAAIMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM' + +const blob = (base64: string): Buffer => Buffer.from(base64, 'base64') +const hostLine = (hosts: string, key: string): string => `${hosts} ssh-ed25519 ${key}\n` + +function verdict(entries: KnownHostsEntry[], host: string, key: string): string { + return matchKnownHosts(entries, { + host, + port: 22, + keyType: readHostKeyType(blob(key)) ?? '', + key: blob(key) + }) +} + +const roots: string[] = [] +const savedHome = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE } + +async function createRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-known-hosts-')) + roots.push(root) + return root +} + +/** os.homedir() reads HOME on POSIX and USERPROFILE on Windows. */ +function pretendHomeIs(path: string): void { + process.env.HOME = path + process.env.USERPROFILE = path +} + +function resolvedConfig(overrides: Partial = {}): SshResolvedConfig { + return { + hostname: 'prod.internal', + port: 22, + identityFile: [], + identitiesOnly: false, + forwardAgent: false, + proxyUseFdpass: false, + controlMaster: 'no', + controlPersist: 'no', + userKnownHostsFiles: [], + globalKnownHostsFiles: [], + strictHostKeyChecking: 'ask', + hashKnownHosts: false, + updateHostKeys: 'no', + ...overrides + } +} + +afterEach(async () => { + for (const key of ['HOME', 'USERPROFILE'] as const) { + const value = savedHome[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('resolveKnownHostsFiles', () => { + it('splits the space-separated list ssh -G prints on one line', () => { + const resolved = parseSshGOutput( + [ + 'hostname prod.internal', + 'userknownhostsfile /a/known_hosts /a/known_hosts2 /b/known_hosts', + 'globalknownhostsfile /etc/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts2' + ].join('\n') + ) + + expect(resolved.userKnownHostsFiles).toEqual([ + '/a/known_hosts', + '/a/known_hosts2', + '/b/known_hosts' + ]) + expect(resolveKnownHostsFiles(resolved)).toEqual([ + '/a/known_hosts', + '/a/known_hosts2', + '/b/known_hosts', + '/etc/ssh/ssh_known_hosts', + '/etc/ssh/ssh_known_hosts2' + ]) + }) + + it('expands ~ in a reported path', () => { + pretendHomeIs(join('/pretend', 'home')) + + const resolved = parseSshGOutput('userknownhostsfile ~/.ssh/known_hosts ~/other_hosts') + + expect(resolveKnownHostsFiles(resolved)).toEqual([ + join('/pretend', 'home', '.ssh', 'known_hosts'), + join('/pretend', 'home', 'other_hosts') + ]) + }) + + it('keeps a double-quoted path containing spaces whole', () => { + const resolved = parseSshGOutput( + 'userknownhostsfile "/Users/dev/my hosts/known_hosts" /plain/known_hosts' + ) + + expect(resolveKnownHostsFiles(resolved)).toEqual([ + '/Users/dev/my hosts/known_hosts', + '/plain/known_hosts' + ]) + }) + + it('falls back to the default files when ssh -G reported nothing', () => { + pretendHomeIs(join('/pretend', 'home')) + + // Why not an empty list: no ssh, a non-zero exit or a timeout must not turn a host the user + // already verified into first contact. + expect(resolveKnownHostsFiles(null)).toEqual([ + join('/pretend', 'home', '.ssh', 'known_hosts'), + join('/pretend', 'home', '.ssh', 'known_hosts2') + ]) + expect(defaultKnownHostsFiles()).toEqual(resolveKnownHostsFiles(null)) + }) + + it('drops an explicit none without falling back to the defaults', () => { + const resolved = parseSshGOutput( + ['userknownhostsfile none', 'globalknownhostsfile /etc/ssh/ssh_known_hosts'].join('\n') + ) + + expect(resolveKnownHostsFiles(resolved)).toEqual(['/etc/ssh/ssh_known_hosts']) + }) +}) + +describe('loadKnownHostsEntries', () => { + it('unions the entries of every file', async () => { + const root = await createRoot() + const userFile = join(root, 'known_hosts') + const globalFile = join(root, 'ssh_known_hosts') + await writeFile(userFile, hostLine('alpha.example', ED_A)) + await writeFile(globalFile, hostLine('beta.example', ED_B)) + + const entries = await loadKnownHostsEntries([userFile, globalFile]) + + expect(entries).toHaveLength(2) + expect(verdict(entries, 'alpha.example', ED_A)).toBe('match') + expect(verdict(entries, 'beta.example', ED_B)).toBe('match') + }) + + it('lets a hit in either file win over a disagreeing entry in the other', async () => { + const root = await createRoot() + const first = join(root, 'first') + const second = join(root, 'second') + await writeFile(first, hostLine('shared.example', ED_A)) + await writeFile(second, hostLine('shared.example', ED_B)) + + const entries = await loadKnownHostsEntries([first, second]) + + expect(verdict(entries, 'shared.example', ED_A)).toBe('match') + expect(verdict(entries, 'shared.example', ED_B)).toBe('match') + // The union still detects a key neither file holds. + expect(verdict(entries, 'shared.example', ED_C)).toBe('mismatch') + }) + + it('skips a missing file and keeps the rest', async () => { + const root = await createRoot() + const present = join(root, 'known_hosts') + await writeFile(present, hostLine('alpha.example', ED_A)) + + const entries = await loadKnownHostsEntries([join(root, 'absent'), present]) + + expect(verdict(entries, 'alpha.example', ED_A)).toBe('match') + }) + + it('skips a path that is a directory', async () => { + const root = await createRoot() + const present = join(root, 'known_hosts') + await mkdir(join(root, 'a_directory')) + await writeFile(present, hostLine('alpha.example', ED_A)) + + const entries = await loadKnownHostsEntries([join(root, 'a_directory'), present]) + + expect(verdict(entries, 'alpha.example', ED_A)).toBe('match') + }) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'skips an unreadable file and keeps the rest', + async () => { + const root = await createRoot() + const unreadable = join(root, 'unreadable') + const present = join(root, 'known_hosts') + await writeFile(unreadable, hostLine('secret.example', ED_B)) + await chmod(unreadable, 0o000) + await writeFile(present, hostLine('alpha.example', ED_A)) + + const entries = await loadKnownHostsEntries([unreadable, present]) + + expect(verdict(entries, 'alpha.example', ED_A)).toBe('match') + expect(verdict(entries, 'secret.example', ED_B)).toBe('unknown') + } + ) + + it('returns nothing when no file can be read', async () => { + const root = await createRoot() + + await expect(loadKnownHostsEntries([join(root, 'absent')])).resolves.toEqual([]) + }) +}) + +describe('resolveKnownHostsLookupHost', () => { + it('prefers HostKeyAlias over the resolved hostname', () => { + // A bastion tunnelled through localhost:2200 would otherwise mismatch on every target. + const resolved = resolvedConfig({ hostname: '127.0.0.1', hostKeyAlias: 'bastion' }) + + expect(resolveKnownHostsLookupHost(resolved, '127.0.0.1')).toBe('bastion') + }) + + it('uses the resolved hostname, never the Orca label', () => { + const resolved = resolvedConfig({ hostname: 'prod.internal' }) + + expect(resolveKnownHostsLookupHost(resolved, 'my-orca-label')).toBe('prod.internal') + }) + + it('falls back to the dialed host when nothing was resolved', () => { + expect(resolveKnownHostsLookupHost(null, 'direct.example')).toBe('direct.example') + expect(resolveKnownHostsLookupHost(resolvedConfig({ hostname: '' }), 'direct.example')).toBe( + 'direct.example' + ) + }) +}) diff --git a/src/main/ssh/ssh-known-hosts-source.ts b/src/main/ssh/ssh-known-hosts-source.ts new file mode 100644 index 00000000000..091d79b21bd --- /dev/null +++ b/src/main/ssh/ssh-known-hosts-source.ts @@ -0,0 +1,66 @@ +/** + * Where `known_hosts` entries come from for one lookup. + * + * Reading the user's real files is the entire migration story — most developers already verified + * their hosts through `ssh` and `git`. We only read; writing to a file shared with every other SSH + * tool on the machine is out of scope. See docs/reference/ssh-host-key-verification.md (D1, D2). + */ +import { readFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { SshResolvedConfig } from './ssh-g-config-resolution' +import { parseKnownHosts, type KnownHostsEntry } from './ssh-known-hosts' + +/** OpenSSH's explicit opt-out for one list; the other list still applies. */ +const EXPLICIT_NONE = 'none' + +/** Used when `ssh -G` told us nothing — never "no trust source". */ +export function defaultKnownHostsFiles(): string[] { + const home = homedir() + return [join(home, '.ssh', 'known_hosts'), join(home, '.ssh', 'known_hosts2')] +} + +export function resolveKnownHostsFiles(resolved: SshResolvedConfig | null): string[] { + const reported = resolved + ? [...resolved.userKnownHostsFiles, ...resolved.globalKnownHostsFiles] + : [] + // No `ssh`, a non-zero exit or a timeout must not turn a host the user already verified into + // first contact, so an empty report falls back rather than reading nothing. + if (reported.length === 0) { + return defaultKnownHostsFiles() + } + return [...new Set(reported.filter((path) => path !== EXPLICIT_NONE))] +} + +async function readEntries(path: string): Promise { + try { + return parseKnownHosts(await readFile(path, 'utf8')) + } catch { + // Missing, unreadable or a directory. Skipping is not failing open: a file we cannot read + // contributes no trust, and the remaining files still decide. + return [] + } +} + +/** + * The union across every file. The caller runs `matchKnownHosts` once over the result, so an exact + * hit in any file wins and a disagreeing entry in another file is not a mismatch. + */ +export async function loadKnownHostsEntries( + files: readonly string[] +): Promise { + const perFile = await Promise.all(files.map((path) => readEntries(path))) + return perFile.flat() +} + +/** + * The name the lookup keys on: `HostKeyAlias` when set, else the resolved hostname, else the host + * ssh2 dials. Never the Orca label — bastions tunnelled through `localhost:port` depend on the + * alias, and a label keys on nothing `ssh` ever wrote. + */ +export function resolveKnownHostsLookupHost( + resolved: SshResolvedConfig | null, + dialedHost: string +): string { + return resolved?.hostKeyAlias || resolved?.hostname || dialedHost +} diff --git a/src/main/ssh/ssh-multi-key-authentication.test.ts b/src/main/ssh/ssh-multi-key-authentication.test.ts index 32942facd27..8c355b5db1c 100644 --- a/src/main/ssh/ssh-multi-key-authentication.test.ts +++ b/src/main/ssh/ssh-multi-key-authentication.test.ts @@ -52,6 +52,11 @@ function makeResolved(overrides: Partial = {}): SshResolvedCo proxyUseFdpass: false, controlMaster: 'no', controlPersist: 'no', + userKnownHostsFiles: [], + globalKnownHostsFiles: [], + strictHostKeyChecking: 'ask', + hashKnownHosts: false, + updateHostKeys: 'no', ...overrides } }