diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 8c597c0b325..43ac4898c92 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -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((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 ` // 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 ) { diff --git a/src/main/ssh/ssh-g-config-resolution.ts b/src/main/ssh/ssh-g-config-resolution.ts index b4257d24406..5ec7ece2708 100644 --- a/src/main/ssh/ssh-g-config-resolution.ts +++ b/src/main/ssh/ssh-g-config-resolution.ts @@ -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 { + // 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 | 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 ` asks OpenSSH for the effective config, including // Include/Match/wildcard inheritance, without reimplementing OpenSSH matching. export function resolveWithSshG(host: string): Promise { diff --git a/src/main/ssh/ssh-host-key-decision.test.ts b/src/main/ssh/ssh-host-key-decision.test.ts index 7a2de472c5c..19472eb27a1 100644 --- a/src/main/ssh/ssh-host-key-decision.test.ts +++ b/src/main/ssh/ssh-host-key-decision.test.ts @@ -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 { 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') + }) +}) diff --git a/src/main/ssh/ssh-host-key-decision.ts b/src/main/ssh/ssh-host-key-decision.ts index b620d5c040f..f0eb1afd2f5 100644 --- a/src/main/ssh/ssh-host-key-decision.ts +++ b/src/main/ssh/ssh-host-key-decision.ts @@ -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)}` }` ) } diff --git a/src/main/ssh/ssh-host-key-store.ts b/src/main/ssh/ssh-host-key-store.ts index fb713cfe100..6b429fab71a 100644 --- a/src/main/ssh/ssh-host-key-store.ts +++ b/src/main/ssh/ssh-host-key-store.ts @@ -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 diff --git a/src/main/ssh/ssh-host-key-verifier.test.ts b/src/main/ssh/ssh-host-key-verifier.test.ts index 752edf94689..b594636006c 100644 --- a/src/main/ssh/ssh-host-key-verifier.test.ts +++ b/src/main/ssh/ssh-host-key-verifier.test.ts @@ -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']) ) }) diff --git a/src/main/ssh/ssh-host-key-verifier.ts b/src/main/ssh/ssh-host-key-verifier.ts index 08c5effd467..b7e4d719b18 100644 --- a/src/main/ssh/ssh-host-key-verifier.ts +++ b/src/main/ssh/ssh-host-key-verifier.ts @@ -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 })