mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
feat(ssh): build the host key verifier and the algorithm order that makes it safe
Still not wired into the handshake — that lands next. This is the piece that turns a decision into an ssh2 callback, plus the half of the design that is easy to forget because it lives in a different config field. The verifier MUST be a plain function returning undefined. ssh2 does 'const ret = verifier(key, verify); if (ret !== undefined) verify(ret)', so an async function returns a Promise — neither undefined nor falsy — and ssh2 accepts the key immediately while ignoring whatever the callback later decides. Making this async would silently restore exactly the accept-everything behaviour the module exists to remove, so a test asserts the return value is undefined. orderServerHostKeyAlgorithms is what makes type-scoped matching safe rather than a downgrade. RFC 4253 gives the client's algorithm order priority, so leading with the types we already hold for a host denies a server the choice of presenting some other type to convert a hard failure into first contact. Without it, an attacker who cannot forge the key on file just offers a different algorithm. Revoked entries never contribute to that order. Also fails closed on two paths that would otherwise hang or over-trust: a key whose own length-prefixed header cannot be read is refused rather than reasoned about, and a throw from any dependency denies, because ssh2 may not catch an exception raised inside the verifier and the handshake would hang instead of failing. 18 tests. Includes the two negative cases that matter — first-contact keys are recorded, but keys we already know, rejected keys, ephemeral runtime targets and a lax StrictHostKeyChecking are not.
This commit is contained in:
@@ -29,6 +29,11 @@ function resolved(overrides: Partial<SshResolvedConfig> = {}): SshResolvedConfig
|
|||||||
proxyUseFdpass: false,
|
proxyUseFdpass: false,
|
||||||
controlMaster: 'no',
|
controlMaster: 'no',
|
||||||
controlPersist: 'no',
|
controlPersist: 'no',
|
||||||
|
userKnownHostsFiles: [],
|
||||||
|
globalKnownHostsFiles: [],
|
||||||
|
strictHostKeyChecking: 'ask',
|
||||||
|
hashKnownHosts: false,
|
||||||
|
updateHostKeys: 'no',
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -385,6 +385,11 @@ function makeResolved(overrides?: Partial<SshResolvedConfig>): SshResolvedConfig
|
|||||||
proxyUseFdpass: false,
|
proxyUseFdpass: false,
|
||||||
controlMaster: 'no',
|
controlMaster: 'no',
|
||||||
controlPersist: 'no',
|
controlPersist: 'no',
|
||||||
|
userKnownHostsFiles: [],
|
||||||
|
globalKnownHostsFiles: [],
|
||||||
|
strictHostKeyChecking: 'ask',
|
||||||
|
hashKnownHosts: false,
|
||||||
|
updateHostKeys: 'no',
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,6 +227,11 @@ function createResolvedConfig(overrides?: Partial<SshResolvedConfig>): SshResolv
|
|||||||
proxyUseFdpass: true,
|
proxyUseFdpass: true,
|
||||||
controlMaster: 'no',
|
controlMaster: 'no',
|
||||||
controlPersist: 'no',
|
controlPersist: 'no',
|
||||||
|
userKnownHostsFiles: [],
|
||||||
|
globalKnownHostsFiles: [],
|
||||||
|
strictHostKeyChecking: 'ask',
|
||||||
|
hashKnownHosts: false,
|
||||||
|
updateHostKeys: 'no',
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ export type SshResolvedConfig = {
|
|||||||
controlMaster: string
|
controlMaster: string
|
||||||
controlPath?: string
|
controlPath?: string
|
||||||
controlPersist: 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
|
const SSH_G_TIMEOUT_MS = 5000
|
||||||
@@ -118,6 +125,41 @@ export function parseSshGOutput(stdout: string): SshResolvedConfig {
|
|||||||
return buildSshResolvedConfig(map, identityFiles)
|
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(
|
function buildSshResolvedConfig(
|
||||||
map: Map<string, string>,
|
map: Map<string, string>,
|
||||||
identityFiles: string[]
|
identityFiles: string[]
|
||||||
@@ -150,6 +192,13 @@ function buildSshResolvedConfig(
|
|||||||
proxyJump,
|
proxyJump,
|
||||||
controlMaster: map.get('controlmaster') ?? 'no',
|
controlMaster: map.get('controlmaster') ?? 'no',
|
||||||
controlPath,
|
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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Parameters<typeof isTrusted>[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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string, unknown>
|
||||||
|
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<TrustedHostKeyRecord[]> {
|
||||||
|
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<HostKeyStoreFile> | 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<HostKeyStoreOutcome> {
|
||||||
|
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<TrustedHostKeyRecord> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
await mkdir(dirname(storeFile), { recursive: true }).catch(() => {})
|
||||||
|
await writeSidecarSnapshot(storeFile, {
|
||||||
|
version: STORE_VERSION,
|
||||||
|
hostKeys
|
||||||
|
} satisfies HostKeyStoreFile)
|
||||||
|
}
|
||||||
@@ -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> = {}): 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<HostKeyVerifierDeps>,
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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> = {}): 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'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<KnownHostsEntry[]> {
|
||||||
|
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<KnownHostsEntry[]> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -52,6 +52,11 @@ function makeResolved(overrides: Partial<SshResolvedConfig> = {}): SshResolvedCo
|
|||||||
proxyUseFdpass: false,
|
proxyUseFdpass: false,
|
||||||
controlMaster: 'no',
|
controlMaster: 'no',
|
||||||
controlPersist: 'no',
|
controlPersist: 'no',
|
||||||
|
userKnownHostsFiles: [],
|
||||||
|
globalKnownHostsFiles: [],
|
||||||
|
strictHostKeyChecking: 'ask',
|
||||||
|
hashKnownHosts: false,
|
||||||
|
updateHostKeys: 'no',
|
||||||
...overrides
|
...overrides
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user