Compare commits

...

3 Commits

Author SHA1 Message Date
陈大猫
7da983a56c ci: auto-bump Homebrew tap on stable release tags (#938) (#976)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
After the GitHub Release is published, push an updated Cask to
binaricat/homebrew-netcatty so `brew install binaricat/netcatty/netcatty`
stays current within minutes of the release. Stable tags only — prerelease
tags (v1.2.0-rc.1 etc.) are skipped to keep brew users on stable.

Implementation:
- New script .github/scripts/bump-homebrew-cask.sh computes SHA-256 of the
  arm64 + x64 DMGs already downloaded by the release job, sed-patches the
  Cask file in the tap repo, sanity-checks the result parses as Ruby, and
  pushes the bump. Idempotent on re-run when checksums match.
- New homebrew-tap job in build.yml runs after the release job on the same
  stable-tag gate, downloads the macOS artifact bundle, then runs the
  bump script with HOMEBREW_TAP_TOKEN.

Requires HOMEBREW_TAP_TOKEN secret with contents:write on
binaricat/homebrew-netcatty. With the secret missing the job will fail
fast at the env-var check with no side effects (no push attempted).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 15:01:26 +08:00
陈大猫
344b226ce8 Fix #969: auto-fill saved password into PAM-style keyboard-interactive prompts (#974)
* Fix #969: auto-fill saved password into PAM-style keyboard-interactive prompts

Servers running stock PAM Linux configurations (most distros) only advertise
`keyboard-interactive` as their auth method, not `password` — so even when
the user has saved a password on the host, Netcatty was popping a modal
asking them to type it again. Every connect ended up being a two-password
flow: one to dispatch, one in the modal.

The shared `createKeyboardInteractiveHandler` factory now recognizes the
classic "PAM-wrapped password" challenge (a single prompt with
`echo === false`) and finishes it with the saved password directly,
skipping the modal. Real multi-prompt or echo-visible challenges (2FA / OTP
/ security questions) still go to the modal as before, and a wrong-password
auto-fill on the first attempt falls back to the modal on the retry so the
user can correct it.

Also consolidated startSSHSession's inline keyboard-interactive handler —
which duplicated ~45 lines of the factory logic without the auto-fill
fix — to use the factory with progress callbacks. The chain / SFTP /
port-forwarding bridges already went through the factory and pick up the
auto-fill for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review: only auto-fill prompts that mention a password

The previous heuristic ("single prompt + echo=false + saved password →
auto-fill") would also fire for OTP / Duo / hardware-token challenges,
which are single hidden-echo prompts too. That would burn one auth
attempt per reconnect on those servers and could trip pam_faillock /
pam_tally2 lockout policies before the user ever saw the modal.

Add a prompt-text gate: auto-fill only when the prompt contains a known
password keyword (Latin "password" / "passwd"; CJK "密码" / "口令").
Custom-localized prompts that don't match fall through to the modal,
which is the same behavior as the pre-#969 baseline — strictly no
worse than before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review (round 2): exclude OTP vocabulary from auto-fill

The previous PASSWORD_PROMPT_PATTERN matched anything containing "password"
/ "passwd" / "密码" / "口令", which still let through OTP shapes that
happen to include those words: "Enter your one-time password", "动态密码"
(Chinese for "dynamic password" = OTP), "动态口令", "一次性密码", etc.

Add an OTP/MFA vocabulary check that runs before the password keyword
check. Any prompt containing OTP terminology (one-time, OTP, verification,
passcode, token, 2FA, two-factor, MFA, Duo, 动态, 一次性, 验证码, 令牌,
双因素, 多因素, 短信验证, 手机验证) is disqualified from auto-fill even
if it also matches the password keywords.

Tests cover both English "One-time password" and the three common Chinese
OTP phrasings, plus a regression guard that normal sudo-style password
prompts still auto-fill.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:36:07 +08:00
陈大猫
86e47b5f9e Fix #972: stop false "fingerprint changed" warnings on every SSH connect (#973)
The host-key verifier was misclassifying connections as `changed` in three
situations that had nothing to do with a real key rotation:

1. Records imported from the system `~/.ssh/known_hosts` (or older builds)
   landed in localStorage without a `fingerprint` field. The verifier then
   re-derived the fingerprint from the stored `publicKey` blob on every
   connect — a brittle path that produced a different value than ssh2 if
   anything about the serialization differed by even one byte.
2. `classifyHostKey` had a loose "single candidate with unknown / empty
   keyType → changed" heuristic. Any imported record whose keyType failed
   to parse would be promoted to a rotation warning the first time the
   server presented a real algorithm, even though the user had never
   actually trusted any fingerprint for that algorithm.
3. A host that genuinely had multiple algorithms (e.g. one stored ssh-rsa
   record plus a live ssh-ed25519 handshake) was being reported as
   `changed` instead of `unknown`, even though we had no comparable
   record for the algorithm the server presented.

Tabby (`tabby-ssh/src/session/ssh.ts`) and OpenSSH both treat case (3) as a
first-time prompt rather than a mismatch; this change brings Netcatty in
line with that model.

Changes:
- `domain/knownHosts.ts` ports `fingerprintFromPublicKey` to TS and adds
  `normalizeKnownHost` / `normalizeKnownHosts` so the renderer can backfill
  legacy records on hydration. Pure-JS SHA-256 keeps the migration
  synchronous so it can run inline in `useVaultState` without async
  plumbing.
- `application/state/useVaultState.ts` runs the migration on hydration
  and on cross-window storage events. When anything changes on hydration
  the migrated list is written back to localStorage so the next launch
  starts clean.
- `components/KnownHostsManager.tsx` populates `fingerprint` at import
  time instead of leaving it for the verifier to re-derive.
- `electron/bridges/hostKeyVerifier.cjs` simplifies `classifyHostKey` to
  fingerprint-first, then strict (host, port, keyType) match for the
  changed branch, then fall through to `unknown`. Two existing tests
  that locked in the loose heuristic are updated to assert the new
  (safer) behavior, and a new test covers the multi-algorithm
  first-encounter case.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:41:36 +08:00
11 changed files with 929 additions and 88 deletions

89
.github/scripts/bump-homebrew-cask.sh vendored Executable file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env bash
#
# bump-homebrew-cask.sh — push a new version of the Netcatty cask to the
# binaricat/homebrew-netcatty tap.
#
# Called from the release pipeline (`build.yml` → `homebrew-tap` job) after
# the GitHub Release has been published with the signed + notarized DMGs.
# Computes SHA-256 of the arm64 and x64 DMGs, rewrites the cask file, and
# pushes the bump back to the tap repository using HOMEBREW_TAP_TOKEN.
#
# Required env vars:
# VERSION — semver without leading "v" (e.g. 1.1.6)
# HOMEBREW_TAP_TOKEN — PAT with contents:write on the tap repo
#
# Optional env vars:
# TAP_REPO — default: binaricat/homebrew-netcatty
# ARTIFACTS_DIR — default: artifacts
# CASK_PATH — default: Casks/netcatty.rb
set -euo pipefail
: "${VERSION:?VERSION env var required (no leading v)}"
: "${HOMEBREW_TAP_TOKEN:?HOMEBREW_TAP_TOKEN env var required}"
TAP_REPO="${TAP_REPO:-binaricat/homebrew-netcatty}"
ARTIFACTS_DIR="${ARTIFACTS_DIR:-artifacts}"
CASK_PATH="${CASK_PATH:-Casks/netcatty.rb}"
ARM_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-arm64.dmg"
X64_DMG="${ARTIFACTS_DIR}/Netcatty-${VERSION}-mac-x64.dmg"
for f in "$ARM_DMG" "$X64_DMG"; do
if [[ ! -f "$f" ]]; then
echo "::error::Required DMG artifact not found: $f"
exit 1
fi
done
ARM_SHA=$(shasum -a 256 "$ARM_DMG" | awk '{print $1}')
X64_SHA=$(shasum -a 256 "$X64_DMG" | awk '{print $1}')
echo "Computed checksums:"
echo " arm64: ${ARM_SHA}"
echo " x64 : ${X64_SHA}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
git clone --depth 1 \
"https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${TAP_REPO}.git" \
"$TMP/tap"
cd "$TMP/tap"
if [[ ! -f "$CASK_PATH" ]]; then
echo "::error::Cask file not found in tap: $CASK_PATH"
exit 1
fi
# Patch the cask in place. The three lines we touch are anchored well enough
# that we don't need anything fancier than sed:
# - the `version "X.Y.Z"` line (single line, anchored to start)
# - the `sha256 arm: "..."` line
# - the ` intel: "..."` line (anchor on "intel:" at start, after the
# leading whitespace, so we don't accidentally match the `arch arm:
# "...", intel: "..."` line earlier in the file)
sed -i -E 's|^(\s*version)\s+"[^"]+"|\1 "'"$VERSION"'"|' "$CASK_PATH"
sed -i -E 's|(sha256\s+arm:\s+)"[^"]+"|\1"'"$ARM_SHA"'"|' "$CASK_PATH"
sed -i -E 's|^(\s*intel:\s+)"[^"]+"|\1"'"$X64_SHA"'"|' "$CASK_PATH"
# Sanity-check: parsed file should still be valid Ruby. Catches a broken
# substitution before we push.
if command -v ruby >/dev/null 2>&1; then
ruby -c "$CASK_PATH" >/dev/null
fi
if git diff --quiet; then
echo "Cask already at ${VERSION} with matching checksums — nothing to push."
exit 0
fi
echo "Cask diff:"
git --no-pager diff "$CASK_PATH"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add "$CASK_PATH"
git commit -m "Bump netcatty to ${VERSION}"
git push origin HEAD:main
echo "Pushed bump for ${VERSION} to ${TAP_REPO}."

View File

@@ -604,3 +604,33 @@ jobs:
generate_release_notes: true
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}
homebrew-tap:
name: bump homebrew tap
runs-on: ubuntu-latest
needs: release
# Only stable release tags update the Cask. Prerelease tags
# (e.g. v1.2.0-rc.1) are skipped so brew users stay on stable.
if: |
startsWith(github.ref, 'refs/tags/v')
&& !contains(github.ref_name, '-')
&& (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish_release))
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download macOS artifacts
uses: actions/download-artifact@v4
with:
name: netcatty-macos
path: artifacts/
- name: Bump Cask in binaricat/homebrew-netcatty
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
ARTIFACTS_DIR: artifacts
run: |
# Strip the leading "v" — Cask version is plain semver.
VERSION="${GITHUB_REF_NAME#v}"
export VERSION
bash .github/scripts/bump-homebrew-cask.sh

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import { sanitizeGroupConfig } from "../../domain/groupConfig";
import { normalizeKnownHosts } from "../../domain/knownHosts";
import {
ConnectionLog,
GroupConfig,
@@ -505,11 +506,22 @@ export const useVaultState = () => {
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
// Load known hosts. Records imported from `~/.ssh/known_hosts` and
// records saved by older builds may be missing the `fingerprint` /
// `keyType` fields the verifier compares against; backfill them now
// so the next SSH connect can match without falling into the brittle
// re-derivation path that caused the repeated "fingerprint changed"
// warnings in #972.
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
if (savedKnownHosts) {
const normalized = normalizeKnownHosts(savedKnownHosts);
setKnownHosts(normalized);
if (normalized !== savedKnownHosts) {
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, normalized);
}
}
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
@@ -638,7 +650,7 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_KNOWN_HOSTS) {
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
setKnownHosts(next);
setKnownHosts(normalizeKnownHosts(next));
return;
}

View File

@@ -22,6 +22,7 @@ import React, {
import { useI18n } from "../application/i18n/I18nProvider";
import { useKnownHostsBackend } from "../application/state/useKnownHostsBackend";
import { useStoredViewMode, ViewMode } from "../application/state/useStoredViewMode";
import { fingerprintFromPublicKey } from "../domain/knownHosts";
import { STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE } from "../infrastructure/config/storageKeys";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
@@ -80,12 +81,20 @@ const parseKnownHostsFile = (content: string): KnownHost[] => {
hostname = "(hashed)";
}
const fullPublicKey = `${keyType} ${publicKey}`;
// Compute the fingerprint up front so the SSH host verifier can match
// against this record directly instead of re-deriving on every connect —
// the re-derivation path is where the false "fingerprint changed"
// warnings in #972 originated.
const fingerprint = fingerprintFromPublicKey(fullPublicKey);
parsed.push({
id: `kh-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
hostname,
port,
keyType,
publicKey: `${keyType} ${publicKey}`,
publicKey: fullPublicKey,
fingerprint: fingerprint || undefined,
discoveredAt: Date.now(),
});
} catch {

View File

@@ -1,8 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
import type { KnownHost } from "./models";
import { upsertKnownHost } from "./knownHosts";
import {
fingerprintFromPublicKey,
normalizeKnownHost,
normalizeKnownHosts,
upsertKnownHost,
} from "./knownHosts";
const knownHost = (overrides: Partial<KnownHost> = {}): KnownHost => ({
id: "kh-existing",
@@ -97,3 +103,158 @@ test("upsertKnownHost appends genuinely new host keys", () => {
assert.deepEqual(result, [existing, incoming]);
});
// --- Fingerprint derivation -------------------------------------------------
const makeRawPublicKey = (keyType: string, body = "trusted imported host key") => {
const type = Buffer.from(keyType);
const length = Buffer.alloc(4);
length.writeUInt32BE(type.length, 0);
return Buffer.concat([length, type, Buffer.from(body)]);
};
test("fingerprintFromPublicKey matches Node's SHA-256 over a base64-decoded OpenSSH line", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const base64Body = rawKey.toString("base64");
const expected = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
assert.equal(fingerprintFromPublicKey(`ssh-ed25519 ${base64Body}`), expected);
assert.equal(
fingerprintFromPublicKey(`ssh-ed25519 ${base64Body} comment-tail`),
expected,
"trailing comment is ignored",
);
});
test("fingerprintFromPublicKey strips a SHA256: prefix and trailing padding", () => {
assert.equal(fingerprintFromPublicKey("SHA256:abc123=="), "abc123");
assert.equal(fingerprintFromPublicKey("sha256:abc123"), "abc123");
});
test("fingerprintFromPublicKey returns empty string on missing input", () => {
assert.equal(fingerprintFromPublicKey(undefined), "");
assert.equal(fingerprintFromPublicKey(null), "");
assert.equal(fingerprintFromPublicKey(""), "");
});
// --- Migration --------------------------------------------------------------
test("normalizeKnownHost backfills fingerprint when only publicKey is stored", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const base64Body = rawKey.toString("base64");
const expected = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
const stored: KnownHost = {
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${base64Body}`,
discoveredAt: 1,
};
const migrated = normalizeKnownHost(stored);
assert.notEqual(migrated, stored, "should return a new object when fingerprint is added");
assert.equal(migrated.fingerprint, expected);
assert.equal(migrated.keyType, "ssh-ed25519");
});
test("normalizeKnownHost backfills keyType from an OpenSSH-format publicKey", () => {
const rawKey = makeRawPublicKey("ssh-rsa");
const base64Body = rawKey.toString("base64");
const stored: KnownHost = {
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "",
publicKey: `ssh-rsa ${base64Body}`,
discoveredAt: 1,
};
const migrated = normalizeKnownHost(stored);
assert.equal(migrated.keyType, "ssh-rsa");
});
test("normalizeKnownHost returns the same reference when nothing needs backfilling", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const fp = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
const stored: KnownHost = {
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
fingerprint: fp,
discoveredAt: 1,
};
assert.equal(normalizeKnownHost(stored), stored);
});
test("normalizeKnownHost is a no-op when publicKey is opaque and nothing else is known", () => {
const stored: KnownHost = {
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "unknown",
publicKey: "SHA256:already-just-a-fingerprint",
discoveredAt: 1,
};
const migrated = normalizeKnownHost(stored);
// The SHA256: prefix becomes the fingerprint; keyType stays as "unknown" since
// we cannot recover it from a bare fingerprint.
assert.equal(migrated.fingerprint, "already-just-a-fingerprint");
assert.equal(migrated.keyType, "unknown");
});
test("normalizeKnownHosts returns the same array reference when nothing needs migration", () => {
const rawKey = makeRawPublicKey("ssh-ed25519");
const fp = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
const list: KnownHost[] = [{
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${rawKey.toString("base64")}`,
fingerprint: fp,
discoveredAt: 1,
}];
assert.equal(normalizeKnownHosts(list), list);
});
test("normalizeKnownHosts migrates each entry that needs backfilling", () => {
const rawKeyA = makeRawPublicKey("ssh-ed25519", "host-a-key");
const rawKeyB = makeRawPublicKey("ssh-rsa", "host-b-key");
const fpA = crypto.createHash("sha256").update(rawKeyA).digest("base64").replace(/=+$/g, "");
const fpB = crypto.createHash("sha256").update(rawKeyB).digest("base64").replace(/=+$/g, "");
const list: KnownHost[] = [
{
id: "kh-1",
hostname: "vps-1.example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: `ssh-ed25519 ${rawKeyA.toString("base64")}`,
discoveredAt: 1,
},
{
id: "kh-2",
hostname: "vps-2.example.com",
port: 22,
keyType: "",
publicKey: `ssh-rsa ${rawKeyB.toString("base64")}`,
discoveredAt: 2,
},
];
const migrated = normalizeKnownHosts(list);
assert.notEqual(migrated, list);
assert.equal(migrated[0].fingerprint, fpA);
assert.equal(migrated[1].fingerprint, fpB);
assert.equal(migrated[1].keyType, "ssh-rsa");
});

View File

@@ -36,3 +36,157 @@ export const upsertKnownHost = (
...knownHosts.slice(index + 1),
];
};
const SSH_KEY_TYPE_PREFIX = /^(?:ssh-|ecdsa-|sk-)/;
const stripPadding = (value: string) => value.replace(/=+$/g, "");
// Pure-JS SHA-256 used to migrate stored knownHosts records on hydration.
// crypto.subtle is async and would force the migration through useEffect; for
// a one-shot read-and-rewrite of a typically-small list, the sync path keeps
// the call sites simple. Runs at most a handful of times per app start.
const sha256Bytes = (data: Uint8Array): Uint8Array => {
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
]);
const length = data.length;
const bitLength = BigInt(length) * 8n;
const padded = new Uint8Array(((length + 9 + 63) >> 6) << 6);
padded.set(data);
padded[length] = 0x80;
const view = new DataView(padded.buffer);
view.setBigUint64(padded.length - 8, bitLength, false);
const H = new Uint32Array([
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
]);
const W = new Uint32Array(64);
for (let chunk = 0; chunk < padded.length; chunk += 64) {
for (let i = 0; i < 16; i += 1) W[i] = view.getUint32(chunk + i * 4, false);
for (let i = 16; i < 64; i += 1) {
const s0 = ((W[i - 15] >>> 7) | (W[i - 15] << 25)) ^ ((W[i - 15] >>> 18) | (W[i - 15] << 14)) ^ (W[i - 15] >>> 3);
const s1 = ((W[i - 2] >>> 17) | (W[i - 2] << 15)) ^ ((W[i - 2] >>> 19) | (W[i - 2] << 13)) ^ (W[i - 2] >>> 10);
W[i] = (W[i - 16] + s0 + W[i - 7] + s1) >>> 0;
}
let [a, b, c, d, e, f, g, h] = H;
for (let i = 0; i < 64; i += 1) {
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7));
const ch = (e & f) ^ (~e & g);
const temp1 = (h + S1 + ch + K[i] + W[i]) >>> 0;
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10));
const mj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (S0 + mj) >>> 0;
h = g; g = f; f = e; e = (d + temp1) >>> 0;
d = c; c = b; b = a; a = (temp1 + temp2) >>> 0;
}
H[0] = (H[0] + a) >>> 0; H[1] = (H[1] + b) >>> 0; H[2] = (H[2] + c) >>> 0; H[3] = (H[3] + d) >>> 0;
H[4] = (H[4] + e) >>> 0; H[5] = (H[5] + f) >>> 0; H[6] = (H[6] + g) >>> 0; H[7] = (H[7] + h) >>> 0;
}
const out = new Uint8Array(32);
const outView = new DataView(out.buffer);
for (let i = 0; i < 8; i += 1) outView.setUint32(i * 4, H[i], false);
return out;
};
const base64Decode = (value: string): Uint8Array | null => {
try {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return bytes;
} catch {
return null;
}
};
const base64Encode = (bytes: Uint8Array): string => {
let bin = "";
for (let i = 0; i < bytes.length; i += 1) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
};
/**
* Compute the SHA-256 base64 fingerprint (no padding, no SHA256: prefix) from
* a stored `publicKey` field. Mirrors `fingerprintFromPublicKey` in
* electron/bridges/hostKeyVerifier.cjs so renderer-side migration produces the
* same value the verifier compares against at connect time.
*/
export const fingerprintFromPublicKey = (publicKey: string | undefined | null): string => {
if (typeof publicKey !== "string") return "";
const trimmed = publicKey.trim();
if (!trimmed) return "";
if (/^SHA256:/i.test(trimmed)) {
return stripPadding(trimmed.replace(/^SHA256:/i, ""));
}
const parts = trimmed.split(/\s+/);
if (parts.length >= 2 && SSH_KEY_TYPE_PREFIX.test(parts[0])) {
const bytes = base64Decode(parts[1]);
if (bytes) return stripPadding(base64Encode(sha256Bytes(bytes)));
}
return stripPadding(trimmed);
};
const extractKeyTypeFromPublicKey = (publicKey: string | undefined | null): string => {
if (typeof publicKey !== "string") return "";
const first = publicKey.trim().split(/\s+/)[0] ?? "";
return SSH_KEY_TYPE_PREFIX.test(first) ? first : "";
};
/**
* Backfill missing `fingerprint` / `keyType` on a stored record so the host
* verifier can match it without falling back to the brittle re-derivation
* path. Returns the same reference when nothing changes so callers can skip
* persistence writes and React re-renders.
*/
export const normalizeKnownHost = (knownHost: KnownHost): KnownHost => {
const hasFingerprint = typeof knownHost.fingerprint === "string" && knownHost.fingerprint.length > 0;
const hasKeyType = typeof knownHost.keyType === "string"
&& knownHost.keyType.length > 0
&& knownHost.keyType !== "unknown";
if (hasFingerprint && hasKeyType) return knownHost;
const derivedFingerprint = hasFingerprint
? knownHost.fingerprint!
: fingerprintFromPublicKey(knownHost.publicKey);
const derivedKeyType = hasKeyType
? knownHost.keyType
: extractKeyTypeFromPublicKey(knownHost.publicKey);
const fingerprintChanged = derivedFingerprint && derivedFingerprint !== knownHost.fingerprint;
const keyTypeChanged = derivedKeyType && derivedKeyType !== knownHost.keyType;
if (!fingerprintChanged && !keyTypeChanged) return knownHost;
return {
...knownHost,
fingerprint: fingerprintChanged ? derivedFingerprint : knownHost.fingerprint,
keyType: keyTypeChanged ? derivedKeyType : knownHost.keyType,
};
};
/**
* Normalize a whole list. Returns the same array reference when no entries
* needed migration so referential-equality consumers (React.memo, prop
* comparisons in TerminalLayer) don't re-render on every hydration.
*/
export const normalizeKnownHosts = (knownHosts: KnownHost[]): KnownHost[] => {
let changed = false;
const next = knownHosts.map((entry) => {
const normalized = normalizeKnownHost(entry);
if (normalized !== entry) changed = true;
return normalized;
});
return changed ? next : knownHosts;
};

View File

@@ -81,6 +81,17 @@ const getKnownHostFingerprint = (knownHost) => {
|| fingerprintFromPublicKey(knownHost?.publicKey);
};
// Classification rules, in order:
// 1. Any record for (host, port) whose fingerprint matches the live key →
// trusted. Fingerprint is the ground truth; key type is metadata.
// 2. A record matching (host, port, keyType) *exactly* with a non-matching
// fingerprint → changed. Only this case is a real "key rotated" alarm —
// the user already trusted this exact algorithm on this host and the
// server now presents a different key of the same type.
// 3. Otherwise → unknown. This includes the case where the server presents
// a key of an algorithm we have no record for, even if the host has
// records for other algorithms. Tabby and OpenSSH both treat that as a
// first-time prompt rather than a mismatch warning (#972).
const classifyHostKey = ({ knownHosts = [], hostname, port = 22, keyType, fingerprint }) => {
const normalizedFingerprint = normalizeFingerprint(fingerprint);
const candidates = Array.isArray(knownHosts)
@@ -104,26 +115,17 @@ const classifyHostKey = ({ knownHosts = [], hostname, port = 22, keyType, finger
}
const normalizedKeyType = typeof keyType === "string" ? keyType.trim() : "";
const hasSpecificIncomingType = normalizedKeyType && normalizedKeyType !== "unknown";
let sameTypeMismatch;
if (hasSpecificIncomingType) {
sameTypeMismatch = comparableCandidates.find((entry) => entry.knownHost.keyType === normalizedKeyType);
if (!sameTypeMismatch && comparableCandidates.length === 1) {
const onlyCandidate = comparableCandidates[0];
if (!onlyCandidate.knownHost.keyType || onlyCandidate.knownHost.keyType === "unknown") {
sameTypeMismatch = onlyCandidate;
}
if (normalizedKeyType && normalizedKeyType !== "unknown") {
const sameTypeMismatch = comparableCandidates.find(
(entry) => entry.knownHost.keyType === normalizedKeyType,
);
if (sameTypeMismatch) {
return {
status: "changed",
knownHost: sameTypeMismatch.knownHost,
expectedFingerprint: sameTypeMismatch.fingerprint,
};
}
} else if (comparableCandidates.length === 1) {
sameTypeMismatch = comparableCandidates[0];
}
if (sameTypeMismatch) {
return {
status: "changed",
knownHost: sameTypeMismatch.knownHost,
expectedFingerprint: sameTypeMismatch.fingerprint,
};
}
return { status: "unknown" };

View File

@@ -117,7 +117,11 @@ test("classifyHostKey treats the same hostname on a different port as unknown",
assert.equal(result.status, "unknown");
});
test("classifyHostKey treats unknown incoming key types as comparable for changed hosts", () => {
test("classifyHostKey reports unknown when only the incoming key type is unknown", () => {
// Without a confident key type from ssh2 we cannot tell whether this is a
// rotation of the stored key or a brand-new algorithm; force the user back
// through the first-time-trust path rather than scaring them with a
// "fingerprint changed" warning (#972).
const result = classifyHostKey({
knownHosts: [{
id: "kh-1",
@@ -133,10 +137,13 @@ test("classifyHostKey treats unknown incoming key types as comparable for change
fingerprint: "new-key",
});
assert.equal(result.status, "changed");
assert.equal(result.status, "unknown");
});
test("classifyHostKey treats stored unknown key types as comparable for changed hosts", () => {
test("classifyHostKey reports unknown when the stored record has no key type", () => {
// Legacy / imported records sometimes have an empty or "unknown" keyType.
// Promoting those to "changed" on every connect was the root cause of #972;
// treat them as not-comparable so the user re-confirms cleanly.
const result = classifyHostKey({
knownHosts: [{
id: "kh-1",
@@ -152,7 +159,30 @@ test("classifyHostKey treats stored unknown key types as comparable for changed
fingerprint: "new-key",
});
assert.equal(result.status, "changed");
assert.equal(result.status, "unknown");
});
test("classifyHostKey reports unknown when the server presents a different key type than any stored record", () => {
// Server with ssh-rsa stored; presents ssh-ed25519 this time. OpenSSH treats
// this as a new key offering, not a rotation; we match that behavior so a
// host with multiple algorithms doesn't spam mismatch warnings on every
// algorithm renegotiation.
const result = classifyHostKey({
knownHosts: [{
id: "kh-rsa",
hostname: "switch.local",
port: 22,
keyType: "ssh-rsa",
publicKey: "SHA256:rsa-key",
discoveredAt: 1,
}],
hostname: "switch.local",
port: 22,
keyType: "ssh-ed25519",
fingerprint: "new-key",
});
assert.equal(result.status, "unknown");
});
test("classifyHostKey prefers exact key type mismatches when a host has multiple keys", () => {
@@ -304,7 +334,10 @@ test("createHostVerifier prompts for unknown host keys and waits for user respon
});
test("createHostVerifier includes existing known host details when a key changes", async () => {
const rawKey = Buffer.from("changed server key");
// A well-formed wire blob so `describeHostKey` can recover keyType =
// "ssh-ed25519"; that triggers the strict (host, port, type) mismatch
// branch with a stored record of the same type but different fingerprint.
const rawKey = makeRawPublicKey("ssh-ed25519", "changed server key");
const sent = [];
const sender = {
id: 1,
@@ -322,6 +355,7 @@ test("createHostVerifier includes existing known host details when a key changes
port: 22,
keyType: "ssh-ed25519",
publicKey: "SHA256:old-key",
fingerprint: "old-key",
discoveredAt: 1,
}],
});

View File

@@ -717,18 +717,113 @@ function buildAuthHandler(options) {
};
}
// OTP / MFA / token vocabulary. Matched FIRST — any hit here disqualifies the
// challenge from auto-fill even if it also contains a "password" keyword.
// Catches phrases like "One-time password", "动态密码", "动态口令",
// "一次性密码", "Verification code", "Duo passcode", "two-factor", etc.
// — all single-prompt shapes that look like password fields on the surface
// but actually want an OTP. Submitting the saved password into any of these
// burns an auth attempt and risks `pam_faillock` / `pam_tally2` lockout.
// (#969 PR review, second round.)
const OTP_PROMPT_PATTERN = new RegExp(
[
"one[\\s-]?time",
"\\botp\\b",
"verification",
"passcode",
"\\btoken\\b",
"2fa",
"two[\\s-]?factor",
"multi[\\s-]?factor",
"\\bmfa\\b",
"second\\s+factor",
"duo",
// CJK — no word boundaries; substring match is intentional
"动态",
"一次性",
"验证码",
"验证信息",
"令牌",
"双因素",
"多因素",
"短信验证",
"手机验证",
].join("|"),
"i",
);
// Latin-script + CJK keywords for "this prompt is asking for a reusable
// password". Only consulted AFTER OTP_PROMPT_PATTERN clears, so phrases like
// "One-time password" or "动态密码" never reach this step.
//
// Custom-localized prompts that don't match these keywords fall through to
// the modal, which is the same behavior as before the auto-fill optimization
// — strictly no worse than the old "always prompt" baseline.
const PASSWORD_PROMPT_PATTERN = /passw(or)?d|密\s*码|口\s*令/i;
/**
* Decide whether a keyboard-interactive challenge is "just a PAM-wrapped
* password prompt" that we can answer with the saved host password without
* bothering the user. PAM-based Linux servers commonly advertise only
* `keyboard-interactive` (not `password`), so without this shortcut every
* connection pops a second password dialog even when the host already has a
* saved credential — see #969.
*
* Conservative criteria, matching OpenSSH and Tabby behavior:
* - exactly one prompt (multi-prompt is almost certainly real 2FA / MFA)
* - the prompt has `echo === false`
* - the prompt text does NOT contain any OTP / MFA vocabulary
* - the prompt text DOES contain a recognized password keyword (Latin
* "password" / "passwd", CJK "密码" / "口令")
* - we have a non-empty saved password
*
* Anything else falls through to the modal so the user can answer in person.
*/
function isAutoFillablePasswordChallenge(prompts, password) {
if (typeof password !== "string" || password.length === 0) return false;
if (!Array.isArray(prompts) || prompts.length !== 1) return false;
const prompt = prompts[0];
if (!prompt || prompt.echo !== false) return false;
const promptText = typeof prompt.prompt === "string" ? prompt.prompt : "";
if (OTP_PROMPT_PATTERN.test(promptText)) return false;
return PASSWORD_PROMPT_PATTERN.test(promptText);
}
/**
* Create a keyboard-interactive event handler
* @param {Object} options
* @param {Object} options.sender - Electron webContents sender
* @param {string} options.sessionId - Session/connection ID
* @param {string} options.hostname - Host being connected to
* @param {string} [options.password] - Saved password for fill button
* @param {string} [options.password] - Saved password; used both as the
* one-click fill button payload and as the auto-fill for the single-
* password-prompt fast path (#969).
* @param {string} [options.logPrefix] - Log prefix for debugging
* @param {Function} [options.onAutoFill] - Called when the saved password is
* auto-filled into the challenge (no modal shown). Lets callers emit a
* different progress message than the user-prompt flow.
* @param {Function} [options.onPromptShown] - Called right before the modal
* IPC is sent to the renderer.
* @param {Function} [options.onUserResponded] - Called when the renderer
* sends a response back (after the modal closed).
* @returns {Function} - Event handler for 'keyboard-interactive' event
*/
function createKeyboardInteractiveHandler(options) {
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
const {
sender,
sessionId,
hostname,
password,
logPrefix = "[SSH]",
onAutoFill,
onPromptShown,
onUserResponded,
} = options;
// ssh2 may re-invoke the keyboard-interactive event on auth failure with a
// fresh challenge. If our first auto-fill attempt was wrong, falling back
// to the modal on the retry lets the user correct it — and prevents a
// tight loop where we keep submitting the same wrong password.
let autoFilledOnce = false;
return (name, instructions, instructionsLang, prompts, finish) => {
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
@@ -744,10 +839,19 @@ function createKeyboardInteractiveHandler(options) {
return;
}
if (!autoFilledOnce && isAutoFillablePasswordChallenge(prompts, password)) {
autoFilledOnce = true;
console.log(`${logPrefix} Auto-filling saved password into single keyboard-interactive prompt`);
try { onAutoFill?.(); } catch (err) { console.warn(`${logPrefix} onAutoFill callback threw`, err); }
finish([password]);
return;
}
// Forward prompts to user via IPC
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
try { onUserResponded?.(); } catch (err) { console.warn(`${logPrefix} onUserResponded callback threw`, err); }
finish(userResponses);
}, sender.id, sessionId);
@@ -757,6 +861,7 @@ function createKeyboardInteractiveHandler(options) {
}));
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
try { onPromptShown?.(); } catch (err) { console.warn(`${logPrefix} onPromptShown callback threw`, err); }
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
@@ -861,6 +966,7 @@ module.exports = {
getAvailableAgentSocket,
buildAuthHandler,
createKeyboardInteractiveHandler,
isAutoFillablePasswordChallenge,
applyAuthToConnOpts,
safeSend,
requestPassphrasesForEncryptedKeys,

View File

@@ -0,0 +1,270 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
createKeyboardInteractiveHandler,
isAutoFillablePasswordChallenge,
} = require("./sshAuthHelper.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const createSender = () => {
const sent = [];
return {
sent,
sender: {
id: 42,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
},
};
};
// Settles any modal requests that the handler queued via storeRequest so the
// 5-minute TTL timer doesn't keep the test process alive.
const drainPendingRequests = (sent) => {
for (const event of sent) {
if (event.channel !== "netcatty:keyboard-interactive") continue;
const requestId = event.payload?.requestId;
if (requestId) {
keyboardInteractiveHandler.handleResponse(null, { requestId, cancelled: true });
}
}
};
const passwordPrompt = { prompt: "Password:", echo: false };
const linuxPasswordPrompt = { prompt: "[sudo] password for alice:", echo: false };
const verificationCodePrompt = { prompt: "Verification code:", echo: true };
const otpPrompt = { prompt: "Verification code:", echo: false }; // Google Auth / TOTP
const duoPrompt = { prompt: "Duo two-factor login\nPasscode or option (1-1):", echo: false };
const cjkPasswordPrompt = { prompt: "密码:", echo: false };
const customizedAuthPrompt = { prompt: "Please authenticate:", echo: false };
// OTP prompts that DO mention the word "password" or "口令" — the literal
// keyword should not be enough to trigger auto-fill (#969 PR review round 2).
const oneTimePasswordPrompt = { prompt: "Enter your one-time password:", echo: false };
const cjkDynamicPasswordPrompt = { prompt: "动态密码:", echo: false };
const cjkDynamicTokenPrompt = { prompt: "动态口令:", echo: false };
const cjkOneTimePasswordPrompt = { prompt: "一次性密码:", echo: false };
// --- isAutoFillablePasswordChallenge ---------------------------------------
test("isAutoFillablePasswordChallenge accepts a single hidden-echo prompt with a saved password", () => {
assert.equal(isAutoFillablePasswordChallenge([passwordPrompt], "hunter2"), true);
});
test("isAutoFillablePasswordChallenge rejects multi-prompt challenges (likely 2FA)", () => {
assert.equal(
isAutoFillablePasswordChallenge([passwordPrompt, verificationCodePrompt], "hunter2"),
false,
);
});
test("isAutoFillablePasswordChallenge rejects echo=true prompts (could be username / OTP)", () => {
assert.equal(isAutoFillablePasswordChallenge([verificationCodePrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects when no saved password is available", () => {
assert.equal(isAutoFillablePasswordChallenge([passwordPrompt], ""), false);
assert.equal(isAutoFillablePasswordChallenge([passwordPrompt], undefined), false);
assert.equal(isAutoFillablePasswordChallenge([passwordPrompt], null), false);
});
test("isAutoFillablePasswordChallenge rejects empty / non-array prompts", () => {
assert.equal(isAutoFillablePasswordChallenge([], "hunter2"), false);
assert.equal(isAutoFillablePasswordChallenge(undefined, "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects OTP-style hidden prompts (Google Authenticator, TOTP)", () => {
// Single prompt, echo=false, but the text says "Verification code" — that's
// a 2FA challenge, not a password. Submitting the saved password here would
// burn an auth attempt on the server. (#969 PR review)
assert.equal(isAutoFillablePasswordChallenge([otpPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects Duo-style passcode prompts", () => {
// "Passcode" is the term Duo uses for the OTP, not a reusable password.
// Treat it as a 2FA challenge.
assert.equal(isAutoFillablePasswordChallenge([duoPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge accepts CJK password prompts", () => {
// PAM on Chinese-locale Linux often renders "密码:" — the user still
// expects the saved password to work.
assert.equal(isAutoFillablePasswordChallenge([cjkPasswordPrompt], "hunter2"), true);
});
test("isAutoFillablePasswordChallenge falls through to the modal for unrecognized prompt text", () => {
// Custom prompts that don't mention a known keyword stay on the safe side
// — the user sees the modal as before. No regression from the old
// always-prompt baseline.
assert.equal(isAutoFillablePasswordChallenge([customizedAuthPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects 'One-time password' even though it contains the word 'password'", () => {
// PR review round 2: the OTP vocabulary check must run before the password
// keyword check, otherwise "password" in "One-time password" triggers a
// false-positive auto-fill that burns a 2FA attempt.
assert.equal(isAutoFillablePasswordChallenge([oneTimePasswordPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge rejects Chinese OTP prompts ('动态密码', '动态口令', '一次性密码')", () => {
// The Chinese "动态密码" / "动态口令" / "一次性密码" idioms specifically
// mean OTP. Mustn't auto-fill the reusable password into them.
assert.equal(isAutoFillablePasswordChallenge([cjkDynamicPasswordPrompt], "hunter2"), false);
assert.equal(isAutoFillablePasswordChallenge([cjkDynamicTokenPrompt], "hunter2"), false);
assert.equal(isAutoFillablePasswordChallenge([cjkOneTimePasswordPrompt], "hunter2"), false);
});
test("isAutoFillablePasswordChallenge accepts a sudo-style password prompt", () => {
// Regression guard: the OTP deny-list should not over-block normal Linux
// PAM prompts that legitimately mention a username after "password".
assert.equal(isAutoFillablePasswordChallenge([linuxPasswordPrompt], "hunter2"), true);
});
// --- createKeyboardInteractiveHandler --------------------------------------
test("createKeyboardInteractiveHandler auto-fills the saved password for a single password prompt", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
const finishCalls = [];
handler("", "", "", [passwordPrompt], (responses) => finishCalls.push(responses));
// The handler answered without sending any IPC and without showing a prompt.
assert.deepEqual(sent, []);
assert.deepEqual(promptEvents, []);
assert.deepEqual(autoFillEvents, ["auto-fill"]);
assert.deepEqual(finishCalls, [["hunter2"]]);
});
test("createKeyboardInteractiveHandler falls back to the modal on the retry after a failed auto-fill", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "wrong-password",
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
const finishCalls = [];
// First call — auto-fill fires, no modal shown.
handler("", "", "", [passwordPrompt], (responses) => finishCalls.push({ first: responses }));
// ssh2 re-invokes after auth failure — this time the user must see the modal.
handler("", "", "", [passwordPrompt], (responses) => finishCalls.push({ second: responses }));
assert.deepEqual(autoFillEvents, ["auto-fill"]);
assert.deepEqual(promptEvents, ["prompt-shown"]);
assert.deepEqual(finishCalls, [{ first: ["wrong-password"] }]);
assert.equal(sent.length, 1);
assert.equal(sent[0].channel, "netcatty:keyboard-interactive");
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler shows the modal when the challenge is real 2FA (multiple prompts)", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
handler("Two-factor", "", "", [passwordPrompt, verificationCodePrompt], () => {});
assert.deepEqual(autoFillEvents, []);
assert.deepEqual(promptEvents, ["prompt-shown"]);
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.prompts.length, 2);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler does not auto-fill when no saved password is configured", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: undefined,
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
handler("", "", "", [passwordPrompt], () => {});
assert.deepEqual(autoFillEvents, []);
assert.deepEqual(promptEvents, ["prompt-shown"]);
assert.equal(sent.length, 1);
assert.equal(sent[0].payload.savedPassword, null);
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler shows the modal for OTP-style hidden prompts even with a saved password", () => {
// Regression guard for the #969 PR review: a single hidden-echo prompt
// that doesn't mention "password" must not auto-submit the saved value.
const { sender, sent } = createSender();
const autoFillEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
onAutoFill: () => autoFillEvents.push("auto-fill"),
});
handler("", "", "", [otpPrompt], () => {});
assert.deepEqual(autoFillEvents, []);
assert.equal(sent.length, 1, "modal IPC should fire instead of auto-fill");
assert.equal(sent[0].channel, "netcatty:keyboard-interactive");
drainPendingRequests(sent);
});
test("createKeyboardInteractiveHandler short-circuits when the server sends zero prompts", () => {
const { sender, sent } = createSender();
const autoFillEvents = [];
const promptEvents = [];
const handler = createKeyboardInteractiveHandler({
sender,
sessionId: "session-1",
hostname: "vps-1.example.com",
password: "hunter2",
onAutoFill: () => autoFillEvents.push("auto-fill"),
onPromptShown: () => promptEvents.push("prompt-shown"),
});
const finishCalls = [];
handler("", "", "", [], (responses) => finishCalls.push(responses));
assert.deepEqual(autoFillEvents, []);
assert.deepEqual(promptEvents, []);
assert.deepEqual(sent, []);
assert.deepEqual(finishCalls, [[]]);
});

View File

@@ -633,23 +633,22 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
reject(new Error(errMsg));
});
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
const chainKiHandler = createKeyboardInteractiveHandler({
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
sender,
sessionId,
hostname: hopLabel,
password: jump.password,
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
});
conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
if (prompts && prompts.length > 0) {
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'waiting for user input...');
}
const wrappedFinish = (...args) => {
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'user responded');
finish(...args);
};
chainKiHandler(name, instructions, lang, prompts, wrappedFinish);
});
onAutoFill: () => sendProgress(
i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'using saved password',
),
onPromptShown: () => sendProgress(
i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'waiting for user input...',
),
onUserResponded: () => sendProgress(
i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'user responded',
),
}));
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
conn.connect(connOpts);
});
@@ -1565,51 +1564,26 @@ async function startSSHSession(event, options) {
}
});
// Handle keyboard-interactive authentication (2FA/MFA)
conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
console.log(`${logPrefix} ${options.hostname} keyboard-interactive auth requested`, {
name,
instructions,
promptCount: prompts?.length || 0,
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
});
// If there are no prompts, just call finish with empty array
if (!prompts || prompts.length === 0) {
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
finish([]);
return;
}
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'waiting for user input...');
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
// (Prompt text is admin-customizable and may not contain expected keywords)
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'user responded');
finish(userResponses);
}, sender.id, sessionId);
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
safeSend(sender, "netcatty:keyboard-interactive", {
requestId,
sessionId,
name: name || "",
instructions: instructions || "",
prompts: promptsData,
hostname: options.hostname,
savedPassword: options.password || null, // Pass saved password for optional fill button
});
});
// Handle keyboard-interactive authentication (2FA/MFA). Uses the shared
// factory so PAM-wrapped single-password prompts get auto-filled from
// the saved host password (#969) — same path the chain/SFTP/port-
// forwarding bridges go through.
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
sender,
sessionId,
hostname: options.hostname,
password: options.password,
logPrefix,
onAutoFill: () => sendProgress(
totalHops, totalHops, options.hostname, 'auth-attempt', 'using saved password',
),
onPromptShown: () => sendProgress(
totalHops, totalHops, options.hostname, 'auth-attempt', 'waiting for user input...',
),
onUserResponded: () => sendProgress(
totalHops, totalHops, options.hostname, 'auth-attempt', 'user responded',
),
}));
// Enable keyboard-interactive authentication in authHandler