fix(ssh): act on the host key review — three must-fixes and two hazards

M1. A stale record of ours outranked known_hosts, so the remedy we print did not
work. `ssh-keygen -R host` then reconnect leaves known_hosts holding the NEW key
while our store still holds the old one, and the store was consulted first — the
one state that cure produces was the one state we refused. Permanently, since
nothing in the app clears the store. known_hosts now decides a match first, which
concedes nothing: it is the artefact ssh itself obeys, so an attacker who can
rewrite it has already won. Both directions of the precedence are pinned now; the
rotation case fails without this change.

That leaves one rejection known_hosts cannot cure — a host trusted only on first
contact that later rotates its key. "Remove the saved key" named nothing a user
could find, so it now names the store file.

M2. A superseded attempt could put a passphrase prompt in front of a host we had
just refused. The verifier deliberately does not record a rejection for an attempt
nobody is waiting on, so nothing identified it and ssh2's generic handshake error
walked the credential ladder. Guarded on the generation, which catches it whatever
the error turned out to be. Deliberately NOT by rejecting with a cancellation: an
existing test pins that connect() still reports the raw late-startup error, and
that behaviour did not need to change to fix this.

M3. Every unknown host was refused whenever HOME diverges from the passwd home —
devcontainers, `su`, Nix shells, some corporate launchers — because `-F` makes ssh
ignore /etc/ssh/ssh_config and being blind to a site policy was treated as reason
to refuse. Being blind is only a reason to refuse if we cannot go and look, so it
now asks ssh for the system config on its own and takes the stricter of the two.
Only a probe that fails leaves the strict rule standing. Costs one `ssh -G` on the
rare path that already needed -F.

N1. A rejected key's fingerprint was still adopted, and the relay scopes install
locks by it — locks keyed to a host we refused to talk to.

N2. The fallback algorithm list re-introduced the throw its own comment describes.
ssh2 prepends ssh-ed25519 only when a runtime probe succeeds, so on a build where
that probe fails, proposing it makes generateAlgorithmList throw inside
client.connect — and only for hosts we already know. Not reading ssh2's list is a
reason to leave its defaults alone, not to guess: it returns null now and the
caller skips reordering.

1555 tests pass in src/main/ssh.
This commit is contained in:
Neil
2026-08-16 22:33:14 -07:00
parent 6df4d6cae8
commit 692586fdb9
7 changed files with 271 additions and 53 deletions
+56 -13
View File
@@ -43,9 +43,14 @@ import {
DEFAULT_SERVER_HOST_KEY_ALGORITHMS,
orderServerHostKeyAlgorithms
} from './ssh-host-key-verifier'
import { HostKeyVerificationError, isHostKeyVerificationError } from './ssh-host-key-decision'
import { sshGArgsForHost } from './ssh-g-config-resolution'
import {
HostKeyVerificationError,
isHostKeyVerificationError,
strictestHostKeyChecking
} from './ssh-host-key-decision'
import { resolveSiteStrictHostKeyChecking, sshGArgsForHost } from './ssh-g-config-resolution'
import {
boundSshHostKeyStoreFile,
loadTrustedHostKeys,
matchTrustedHostKeys,
storedKeyTypesForEndpoint,
@@ -745,6 +750,19 @@ export class SshConnection {
throw err
}
// Why generation and not the error: a superseded attempt's host key denial is deliberately
// NOT recorded — the verifier withholds it so a dead attempt cannot clobber the live one's
// outcome — so nothing about the error identifies it, and ssh2's generic handshake failure
// reads as an ordinary fault. Everything below either offers a credential or probes another
// transport, and doing that for a connection nobody is waiting on can put a passphrase prompt
// in front of a host we just refused. Checking the generation catches it whatever the error
// turned out to be, and leaves what connect() rejects with unchanged.
if (this.disposed || connectGeneration !== this.connectGeneration) {
this.proxyProcess?.kill()
this.proxyProcess = null
throw err
}
// Why first: everything below this line either offers a credential or retries on another
// transport. We have just decided this host may not be the one it claims to be, so prompting
// would collect the user's passphrase or password on its behalf, and the system-ssh probe
@@ -1228,9 +1246,17 @@ export class SshConnection {
// by whether it happens to have a ~/.ssh/config — incoherently, since the same broken machine
// WITHOUT one was fully permissive. The flag is a claim about a config file we could not read,
// and when ssh never ran there is no such claim to make.
const siteConfigSuppressed =
const sshGSuppressedSiteConfig =
hostKeyResolved !== null &&
sshGArgsForHost(this.target.configHost || this.target.label).includes('-F')
// Being blind to the site policy is only a reason to refuse if we cannot go and read it. Ask ssh
// for the system config on its own; a value means we are no longer blind, and only a probe that
// fails leaves the strict rule standing. Costs one `ssh -G` on the rare path that already needed
// -F, and nothing on the common one.
const siteStrictHostKeyChecking = sshGSuppressedSiteConfig
? await resolveSiteStrictHostKeyChecking(this.target.configHost || this.target.label)
: null
const siteConfigSuppressed = sshGSuppressedSiteConfig && siteStrictHostKeyChecking === null
const knownHostsEvidence = await loadKnownHostsEvidence(resolveKnownHostsFiles(hostKeyResolved))
const knownHostsEntries = knownHostsEvidence.entries
// Preloaded because ssh2's verifier decides synchronously; the same reason known_hosts is read
@@ -1242,14 +1268,18 @@ export class SshConnection {
console.warn('[ssh] host key store unavailable; falling back to known_hosts only:', err)
return []
})
const serverHostKeyOrder = orderServerHostKeyAlgorithms(
knownHostsEntries,
hostKeyLookupHost,
config.port ?? 22,
DEFAULT_SERVER_HOST_KEY_ALGORITHMS,
storedKeyTypesForEndpoint(trustedHostKeys, hostKeyLookupHost, config.port ?? 22),
isHostKeyAlias
)
// Null means ssh2's own list was unreadable, so there is nothing safe to reorder against and
// ssh2 keeps proposing whatever it supports.
const serverHostKeyOrder = DEFAULT_SERVER_HOST_KEY_ALGORITHMS
? orderServerHostKeyAlgorithms(
knownHostsEntries,
hostKeyLookupHost,
config.port ?? 22,
DEFAULT_SERVER_HOST_KEY_ALGORITHMS,
storedKeyTypesForEndpoint(trustedHostKeys, hostKeyLookupHost, config.port ?? 22),
isHostKeyAlias
)
: undefined
return new Promise<void>((resolve, reject) => {
const client = new SshClient()
let settled = false
@@ -1267,7 +1297,17 @@ export class SshConnection {
// The name we looked up, not the label: the mismatch message prints `ssh-keygen -R <host>`
// and a label removes nothing from known_hosts.
displayHost: hostKeyLookupHost,
strictHostKeyChecking: hostKeyResolved?.strictHostKeyChecking ?? 'ask',
// Why the stricter of the two rather than the user's: on the -F path the resolved value was
// produced without /etc/ssh/ssh_config, so it cannot represent a site policy at all. ssh -G
// also cannot tell "the user set ask" apart from "nothing set it, so ask is the default",
// which is what a faithful first-value-wins merge would need. Taking the stricter can only
// over-restrict against a user who deliberately relaxed a host the site locks down — and
// that case refused outright until now.
hostKeyStoreFile: boundSshHostKeyStoreFile() ?? undefined,
strictHostKeyChecking: strictestHostKeyChecking(
hostKeyResolved?.strictHostKeyChecking,
siteStrictHostKeyChecking
),
isHostKeyAlias,
isEphemeralRuntimeTarget: this.target.owner?.type === 'on-demand-runtime',
siteConfigSuppressed,
@@ -1297,9 +1337,12 @@ export class SshConnection {
onDecision: (decision) => {
// Empty when the blob was not a readable host key: there is nothing to identify, and the
// relay uses this fingerprint to isolate install locks, so blanking it would be worse
// than leaving the previous one.
// than leaving the previous one. A REJECTED key is excluded for the same reason read the
// other way: we refused to talk to that host, so adopting its fingerprint would scope the
// relay's locks to a host we never accepted.
if (
decision.fingerprint &&
decision.action !== 'reject' &&
!this.disposed &&
connectGeneration === this.connectGeneration
) {
+44
View File
@@ -59,6 +59,50 @@ export function sshGArgsForHost(host: string): string[] {
return ['-G', '--', host]
}
/**
* The site-wide StrictHostKeyChecking, read on its own.
*
* Only needed on the `-F` path, where OpenSSH ignores /etc/ssh/ssh_config entirely and the resolved
* config above therefore cannot see a site policy. Pointing `-F` at the null device inverts that: no
* per-user file participates, so what comes back is the system config plus built-in defaults.
*
* Without this the caller had to treat "we could not read the site policy" as "refuse every unknown
* host", which locks out anyone whose HOME diverges from their passwd home — devcontainers, `su`,
* Nix shells, some corporate launchers — with no override. Reading it turns a blind rule into data.
*
* Returns null when ssh cannot answer, and the caller stays blind-and-strict for that case.
*/
export function resolveSiteStrictHostKeyChecking(host: string): Promise<string | null> {
// Windows ssh accepts NUL for the same purpose; both are "a file that exists and is empty".
const nullDevice = process.platform === 'win32' ? 'NUL' : '/dev/null'
return new Promise((resolve) => {
let settled = false
let child: ReturnType<typeof execFile> | undefined
const settle = (value: string | null): void => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
child?.kill()
resolve(value)
}
const timer = setTimeout(() => settle(null), SSH_G_TIMEOUT_MS)
try {
child = execFile(
'ssh',
['-F', nullDevice, '-G', '--', host],
{ timeout: SSH_G_TIMEOUT_MS },
(err, stdout) => {
settle(err ? null : (parseSshGOutput(stdout).strictHostKeyChecking ?? null))
}
)
} catch {
settle(null)
}
})
}
// Why: `ssh -G <host>` asks OpenSSH for the effective config, including
// Include/Match/wildcard inheritance, without reimplementing OpenSSH matching.
export function resolveWithSshG(host: string): Promise<SshResolvedConfig | null> {
+80 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { decideHostKey, type HostKeyDecisionInput } from './ssh-host-key-decision'
import {
decideHostKey,
strictestHostKeyChecking,
type HostKeyDecisionInput
} from './ssh-host-key-decision'
function input(overrides: Partial<HostKeyDecisionInput> = {}): HostKeyDecisionInput {
return {
@@ -21,6 +25,45 @@ describe('deciding what to do with a presented host key', () => {
expect(decideHostKey(input({ storeOutcome: 'match' })).action).toBe('accept')
})
// Precedence between the two sources, pinned in both directions. It was unpinned, and the order
// was wrong: a legitimate key rotation is exactly the case where they disagree.
it('accepts a rotated key once known_hosts holds it, even though our record is stale', () => {
// What the remedy we print produces: `ssh-keygen -R host` then reconnect re-adds the NEW key to
// known_hosts while our store still holds the old one. Refusing here was a permanent lockout,
// because nothing in the app clears the store.
expect(
decideHostKey(input({ knownHostsOutcome: 'match', storeOutcome: 'mismatch' })).action
).toBe('accept')
})
it('still refuses a key known_hosts disagrees with, whatever our record says', () => {
// The direction that must NOT be relaxed: known_hosts is authoritative for refusal too, so a
// stale accept in our store cannot rescue a key ssh itself would reject.
const decision = decideHostKey(input({ knownHostsOutcome: 'mismatch', storeOutcome: 'match' }))
expect(decision.action).toBe('reject')
expect(decision.disagreeingSource).toBe('known-hosts')
})
it('names the store file in a changed-key rejection so the user can act on it', () => {
// Reachable for a host we trusted on first contact and never wrote to known_hosts: there is no
// `ssh-keygen -R` that cures it, so "remove the saved key" has to name a file or it names
// nothing. M1 fixed the case known_hosts CAN rescue; this is the one it cannot.
const decision = decideHostKey(
input({ storeOutcome: 'mismatch', hostKeyStoreFile: '/data/orca/ssh-host-keys.json' })
)
expect(decision.action).toBe('reject')
expect(decision.reason).toContain('/data/orca/ssh-host-keys.json')
})
it('falls back to the generic hint when the store path is unknown', () => {
const decision = decideHostKey(input({ storeOutcome: 'mismatch' }))
expect(decision.action).toBe('reject')
expect(decision.reason).toContain('remove the saved key')
})
it('remembers a first-contact key', () => {
expect(decideHostKey(input()).action).toBe('accept-and-remember')
})
@@ -352,3 +395,39 @@ describe('deciding what to do with a presented host key', () => {
}
})
})
/**
* Merging a user-resolved policy with a separately-probed site policy.
*
* Only reachable on the `-F` path, where OpenSSH ignores /etc/ssh/ssh_config so the per-user
* resolution cannot represent a site policy at all. Before this, being unable to see the site policy
* meant refusing every unknown host — which locked out anyone whose HOME diverges from their passwd
* home (devcontainers, `su`, Nix shells) with no override.
*/
describe('merging the user and site host key policies', () => {
it('keeps the user value when the site could not be read', () => {
// The blind case that still fails strict: a null probe must not invent a policy.
expect(strictestHostKeyChecking('ask', null)).toBe('ask')
expect(strictestHostKeyChecking(undefined, null)).toBe('ask')
})
it('takes a strict site policy over a laxer user value', () => {
expect(strictestHostKeyChecking('ask', 'yes')).toBe('yes')
expect(strictestHostKeyChecking('no', 'true')).toBe('true')
})
it('does not let a lax site policy loosen the user', () => {
// The direction that must never relax: a permissive site config cannot downgrade a user who
// asked for strict, or we end up laxer than ssh itself.
expect(strictestHostKeyChecking('yes', 'no')).toBe('yes')
expect(strictestHostKeyChecking('ask', 'off')).toBe('ask')
})
it('lets the site raise ask to accept-new', () => {
expect(strictestHostKeyChecking('ask', 'accept-new')).toBe('accept-new')
})
it('keeps a strict user value against accept-new', () => {
expect(strictestHostKeyChecking('yes', 'accept-new')).toBe('yes')
})
})
+59 -4
View File
@@ -51,6 +51,14 @@ export type HostKeyDecisionInput = {
displayHost: string
/** Needed for the remedy string: an off-port entry is keyed `[host]:port` in known_hosts. */
port: number
/**
* Our own store's path, named in a changed-key rejection.
*
* Only that rejection needs it, and only because known_hosts cannot cure it: a host we trusted on
* first contact and never wrote to known_hosts has no `ssh-keygen -R` to run, so without naming a
* file the message describes a remedy the user cannot locate.
*/
hostKeyStoreFile?: string
}
/**
@@ -78,9 +86,46 @@ function keygenRemoveTarget(displayHost: string, port: number): string {
const STRICT_VALUES = new Set(['true', 'yes', 'always'])
const LAX_VALUES = new Set(['false', 'no', 'off'])
/**
* The stricter of a user-resolved and a site-resolved StrictHostKeyChecking.
*
* Needed only where the two were read separately: `ssh -F` makes OpenSSH ignore /etc/ssh/ssh_config,
* so the per-user resolution cannot represent a site policy and the site has to be probed on its
* own. Ordering is strict > accept-new > ask > lax, and anything unrecognised is left to the
* caller's own handling by falling through to the user value.
*/
export function strictestHostKeyChecking(
userValue: string | undefined,
siteValue: string | null
): string {
const user = userValue ?? 'ask'
if (siteValue === null) {
return user
}
const site = siteValue.trim().toLowerCase()
if (STRICT_VALUES.has(site) && !STRICT_VALUES.has(user.trim().toLowerCase())) {
return site
}
// A site that is lax never loosens a user who is not: refusing to write trust is the safe side.
return LAX_VALUES.has(site) ? user : site === 'accept-new' && user === 'ask' ? site : user
}
const CHANGED_KEY_HINT =
'If you rebuilt or reprovisioned this machine, remove the saved key and reconnect.'
/**
* The same hint, naming the file when we know it.
*
* "Remove the saved key" names nothing a user can find. This rejection is reachable for a host we
* trusted on first contact and that has since rotated its key legitimately — known_hosts cannot
* rescue that one, so without a concrete path there is no way out of it at all.
*/
function changedKeyHint(hostKeyStoreFile: string | undefined): string {
return hostKeyStoreFile
? `If you rebuilt or reprovisioned this machine, remove its entry from ${hostKeyStoreFile} and reconnect.`
: CHANGED_KEY_HINT
}
/**
* Deliberately avoids the words "authentication failed" and "permission denied": the reconnect
* ladder classifies on those substrings, and a denial that reads as an auth error gets retried
@@ -120,7 +165,8 @@ export function decideHostKey(input: HostKeyDecisionInput): HostKeyDecision {
siteConfigSuppressed,
knownHostsUnreadable,
displayHost,
port
port,
hostKeyStoreFile
} = input
const strict = input.strictHostKeyChecking.toLowerCase()
@@ -148,6 +194,15 @@ export function decideHostKey(input: HostKeyDecisionInput): HostKeyDecision {
)
}
}
// Why known_hosts outranks our own record here: this is what a legitimate key rotation looks like
// once the user has run the remedy we print. `ssh-keygen -R host` then a reconnect leaves
// known_hosts holding the NEW key while our store still holds the old one, and checking the store
// first refused exactly the state that cure produces — permanently, since nothing in the app
// clears the store. Deferring to known_hosts concedes nothing: it is the artefact ssh itself
// obeys, so an attacker who can rewrite it has already won.
if (knownHostsOutcome === 'match') {
return { action: 'accept', outcome: 'match' }
}
if (storeOutcome === 'mismatch') {
return {
action: 'reject',
@@ -155,12 +210,12 @@ export function decideHostKey(input: HostKeyDecisionInput): HostKeyDecision {
disagreeingSource: 'orca-store',
reason: rejection(
displayHost,
`The key changed since you last connected. ${CHANGED_KEY_HINT}`
`The key changed since you last connected. ${changedKeyHint(hostKeyStoreFile)}`
)
}
}
if (knownHostsOutcome === 'match' || storeOutcome === 'match') {
if (storeOutcome === 'match') {
return { action: 'accept', outcome: 'match' }
}
@@ -184,7 +239,7 @@ export function decideHostKey(input: HostKeyDecisionInput): HostKeyDecision {
`The host offered a key of a type we have not seen for it before, while a key of another type is already known. This can mean the host was rebuilt, or that something is impersonating it.${
fromKnownHosts
? ` ssh and git will refuse this host too. Run: ssh-keygen -R ${keygenRemoveTarget(displayHost, port)}`
: ` ${CHANGED_KEY_HINT}`
: ` ${changedKeyHint(hostKeyStoreFile)}`
}`
)
}
+5
View File
@@ -56,6 +56,11 @@ export function initSshHostKeyStoreFile(dataFile: string): void {
configuredStoreFile = getSshHostKeyStoreFile(dataFile)
}
/** The bound store path, for messages that must name the artefact a user has to remove. */
export function boundSshHostKeyStoreFile(): string | null {
return configuredStoreFile
}
/**
* 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
+4 -7
View File
@@ -3,7 +3,6 @@ import { parseKnownHosts } from './ssh-known-hosts'
import {
createHostKeyVerifier,
DEFAULT_SERVER_HOST_KEY_ALGORITHMS,
FALLBACK_SERVER_HOST_KEY_ALGORITHMS,
hostKeyFingerprintOf,
orderServerHostKeyAlgorithms,
type HostKeyVerifierDeps
@@ -219,7 +218,9 @@ describe('the ssh2 default algorithm list', () => {
// client.connect, and only for hosts we already know, since those are the only ones we set
// `algorithms` for.
it('proposes nothing ssh2 would refuse', () => {
for (const algorithm of DEFAULT_SERVER_HOST_KEY_ALGORITHMS) {
// Null is the "could not read it" case, and the caller then leaves ssh2's defaults alone rather
// than proposing a guessed list — which is the only way this loop could ever have failed.
for (const algorithm of DEFAULT_SERVER_HOST_KEY_ALGORITHMS ?? []) {
expect(ssh2Constants.SUPPORTED_SERVER_HOST_KEY).toContain(algorithm)
}
})
@@ -227,14 +228,10 @@ describe('the ssh2 default algorithm list', () => {
// ssh2 prepends ssh-ed25519 only when a runtime probe succeeds, so a copy is not merely stale-able
// — it can be wrong on a build where the probe fails. This asserts the fallback still matches on a
// machine where the probe passes; it is the copy, so drift here is a review, not an outage.
it('keeps the fallback copy in step with ssh2', () => {
expect(FALLBACK_SERVER_HOST_KEY_ALGORITHMS).toEqual(ssh2Constants.DEFAULT_SERVER_HOST_KEY)
})
// Type scoping is only safe because we can promote the type we hold, and RSA is negotiated under
// three different names for one key.
it('contains the RSA signature algorithms the ordering maps onto', () => {
expect(DEFAULT_SERVER_HOST_KEY_ALGORITHMS).toEqual(
expect(DEFAULT_SERVER_HOST_KEY_ALGORITHMS ?? []).toEqual(
expect.arrayContaining(['ssh-rsa', 'rsa-sha2-256', 'rsa-sha2-512'])
)
})
+23 -28
View File
@@ -37,6 +37,8 @@ export type HostKeyVerifierDeps = {
/** Already unioned across every known_hosts file. */
entries: readonly KnownHostsEntry[]
isTrusted: TrustedHostKeyLookup
/** Passed straight through so a changed-key rejection can name the file to edit. */
hostKeyStoreFile?: string
rememberHostKey: (record: {
host: string
port: number
@@ -54,27 +56,6 @@ export type HostKeyVerifierDeps = {
isCurrentAttempt?: () => boolean
}
/**
* Last-resort copy of ssh2 1.17's list, used only if the real one cannot be read.
*
* Deliberately not the primary source. ssh2's list is built at load time and `ssh-ed25519` is
* prepended only when a RUNTIME PROBE succeeds — it signs and verifies with a fixed Ed25519 key. On
* a build where that probe fails, ssh-ed25519 is absent from ssh2's SUPPORTED list too, and
* `generateAlgorithmList` THROWS `Unsupported algorithm: ssh-ed25519` from inside `client.connect`.
* That throw matches no retry classifier, so it is a permanent failure — and it would fire ONLY for
* hosts we already know, since those are the only ones we set `algorithms` for. Working on new
* hosts and failing on trusted ones is about the worst shape a bug can have.
*/
export const FALLBACK_SERVER_HOST_KEY_ALGORITHMS = [
'ssh-ed25519',
'ecdsa-sha2-nistp256',
'ecdsa-sha2-nistp384',
'ecdsa-sha2-nistp521',
'rsa-sha2-512',
'rsa-sha2-256',
'ssh-rsa'
]
/**
* ssh2's own default host-key proposal order, which we reorder rather than replace.
*
@@ -85,7 +66,17 @@ export const FALLBACK_SERVER_HOST_KEY_ALGORITHMS = [
*/
export const DEFAULT_SERVER_HOST_KEY_ALGORITHMS = readSsh2DefaultServerHostKeyAlgorithms()
function readSsh2DefaultServerHostKeyAlgorithms(): string[] {
/**
* Null rather than a copied list when ssh2's own cannot be read.
*
* A hardcoded fallback cannot be safe here. ssh2 prepends `ssh-ed25519` only when a RUNTIME PROBE
* succeeds, and on a build where that probe fails the name is absent from ssh2's SUPPORTED list too,
* so proposing it makes `generateAlgorithmList` THROW `Unsupported algorithm: ssh-ed25519` inside
* client.connect. That throw matches no retry classifier, and it would fire only for hosts we
* already know — working on new hosts and failing on trusted ones. Not reading the list is a reason
* to leave ssh2's defaults alone, not to guess at them.
*/
function readSsh2DefaultServerHostKeyAlgorithms(): string[] | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- ssh2 exports this only here
const constants = require('ssh2/lib/protocol/constants.js') as {
@@ -95,13 +86,16 @@ function readSsh2DefaultServerHostKeyAlgorithms(): string[] {
if (Array.isArray(list) && list.length > 0 && list.every((a) => typeof a === 'string')) {
return [...(list as string[])]
}
console.warn('[ssh] ssh2 default host key list has an unexpected shape; using the copy')
console.warn('[ssh] ssh2 default host key list has an unexpected shape; leaving its defaults')
} catch (error) {
// A future ssh2 could move the file. Falling back keeps connections working; the cost is only
// that the proposal order may drift, which a test reports.
console.warn('[ssh] could not read ssh2 default host key algorithms; using the copy:', error)
// A future ssh2 could move the file. Leaving its defaults keeps connections working; the only
// cost is that a known host is no longer type-scoped, which is the pre-existing behaviour.
console.warn(
'[ssh] could not read ssh2 default host key algorithms; leaving its defaults:',
error
)
}
return [...FALLBACK_SERVER_HOST_KEY_ALGORITHMS]
return null
}
export function hostKeyFingerprintOf(key: Buffer): string {
@@ -233,7 +227,8 @@ export function createHostKeyVerifier(
siteConfigSuppressed: deps.siteConfigSuppressed,
knownHostsUnreadable: deps.knownHostsUnreadable,
displayHost: deps.displayHost,
port: deps.port
port: deps.port,
hostKeyStoreFile: deps.hostKeyStoreFile
})
deps.onDecision?.({ ...decision, fingerprint, keyType })