Files
NetMesh/electron/bridges/sshAuthHelper.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

2125 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* SSH Authentication Helper - Shared authentication logic for SSH connections
* Used by sshBridge, sftpBridge, and portForwardingBridge
*/
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { createHash } = require("node:crypto");
const { exec } = require("node:child_process");
const { utils: sshUtils } = require("ssh2");
const { prepareSystemSshAgent } = require("./systemSshAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const {
normalizePrivateKeyForSsh2,
repairMalformedPem,
PrivateKeyPassphraseError,
InvalidPrivateKeyError,
hasPrivateKeyMaterial,
} = require("./privateKeyNormalizer.cjs");
// Default SSH key names in priority order
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
function orderSshIdentityNames(names) {
const uniqueNames = [...new Set(names)];
const preferred = PREFERRED_KEY_NAMES.filter((name) => uniqueNames.includes(name));
const rest = uniqueNames.filter((name) => !PREFERRED_KEY_NAMES.includes(name)).sort();
return [...preferred, ...rest];
}
class PassphraseCancelledError extends Error {
constructor(keyPath) {
super(`Passphrase entry cancelled for ${keyPath}`);
this.name = "PassphraseCancelledError";
this.code = "ERR_PASSPHRASE_CANCELLED";
this.cancelled = true;
}
}
function isPassphraseCancelledError(err) {
return Boolean(err?.cancelled || err?.code === "ERR_PASSPHRASE_CANCELLED");
}
function createInvalidPrivateKeyError(keyPath, keyName) {
if (keyPath) {
return new InvalidPrivateKeyError(
`Selected SSH identity file does not contain a private key: ${keyPath}`,
);
}
const label = keyName ? ` "${keyName}"` : "";
return new InvalidPrivateKeyError(
`Selected SSH key${label} does not contain a private key`,
);
}
async function readFileNoFollow(filePath) {
const lstat = await fs.promises.lstat(filePath);
if (!lstat.isFile() && !lstat.isSymbolicLink()) return null;
const fd = await fs.promises.open(filePath, "r", 0o0);
try {
return await fs.promises.readFile(fd, { encoding: "utf8" });
} finally {
await fd.close();
}
}
/**
* Quick check if file content looks like an SSH private key.
* Rejects non-key files that happen to match the id_* filename pattern.
*/
function looksLikePrivateKey(content) {
if (!content || typeof content !== "string") return false;
const trimmed = content.trimStart();
return trimmed.startsWith("-----BEGIN") ||
trimmed.startsWith("openssh-key-v1") ||
trimmed.startsWith("PuTTY-User-Key-File");
}
/**
* Check if an SSH private key is encrypted (requires passphrase)
* @param {string} keyContent - The content of the private key file
* @returns {boolean} - True if the key is encrypted
*/
function isKeyEncrypted(keyContent) {
if (!keyContent || typeof keyContent !== "string") return false;
// Check for PuTTY PPK encrypted format (Encryption: aes256-cbc, etc.)
// PPK keys with "Encryption: none" are unencrypted
const ppkEncMatch = keyContent.match(/^Encryption:\s*(.+)$/m);
if (ppkEncMatch && ppkEncMatch[1].trim() !== "none") {
return true;
}
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
return true;
}
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
return true;
}
// Check for DEK-Info header (legacy PEM encryption indicator)
if (keyContent.includes("DEK-Info:")) return true;
// Check for OpenSSH format keys
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
try {
// Repair mangled framing (lost or escaped newlines) first, so the cipher
// name can be read from the base64 blob even when the key was flattened.
const source = repairMalformedPem(keyContent) || keyContent;
// Extract the base64 content between the markers
const base64Match = source.match(
/-----BEGIN OPENSSH PRIVATE KEY-----\s*([\s\S]*?)\s*-----END OPENSSH PRIVATE KEY-----/
);
if (base64Match) {
const base64Content = base64Match[1].replace(/\s/g, "");
const keyBuffer = Buffer.from(base64Content, "base64");
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
// If ciphername is "none", the key is not encrypted
const authMagic = "openssh-key-v1\0";
if (keyBuffer.toString("ascii", 0, authMagic.length) === authMagic) {
// After magic, read ciphername (length-prefixed string)
let offset = authMagic.length;
const cipherNameLen = keyBuffer.readUInt32BE(offset);
offset += 4;
const cipherName = keyBuffer.toString("ascii", offset, offset + cipherNameLen);
return cipherName !== "none";
}
}
} catch {
// If parsing fails, assume it might be encrypted to be safe
return true;
}
}
return false;
}
function expandIdentityFilePath(keyPath) {
return keyPath.startsWith("~/")
? path.join(os.homedir(), keyPath.slice(2))
: keyPath;
}
function notifyPassphraseAuthFailed(sender, keyPath, resolvedPath, keyIds) {
const keyPaths = resolvedPath && resolvedPath !== keyPath
? [keyPath, resolvedPath]
: [keyPath];
try {
if (typeof sender?.isDestroyed === "function" && sender.isDestroyed()) return;
const payload = { keyPaths };
if (Array.isArray(keyIds) && keyIds.length > 0) {
payload.keyIds = keyIds;
}
sender?.send?.("netcatty:passphrase-auth-failed", payload);
} catch {
// Sender may have gone away while authentication was in progress.
}
}
/**
* Resolve a private key (and optional passphrase) into a form ssh2 can parse.
* PKCS#8 keys and PuTTY PPK files that ssh2 rejects are converted first
* (see privateKeyNormalizer.cjs).
*
* @returns {{ privateKey: string, passphrase: string|undefined } | null}
* The usable key, or null when the passphrase is wrong / the key can't be parsed.
* @throws {UnsupportedPrivateKeyError} When a PKCS#8 key has no convertible legacy form.
*/
function resolveKeyForAuth(privateKey, passphrase) {
let normalized;
try {
normalized = normalizePrivateKeyForSsh2(privateKey, passphrase);
} catch (err) {
if (err instanceof PrivateKeyPassphraseError) return null;
throw err;
}
const parsed = sshUtils.parseKey(normalized.privateKey, normalized.passphrase);
if (hasPrivateKeyMaterial(parsed)) {
return { privateKey: normalized.privateKey, passphrase: normalized.passphrase };
}
return null;
}
async function preparePrivateKeyForAuth({
sender,
privateKey,
keyPath,
keyId,
keyName,
hostname,
initialPassphrase,
passphraseSignal,
sessionId,
bootEpoch,
logPrefix = "[SSHAuth]",
onPassphrasePromptShown,
onPassphrasePromptResolved,
}) {
if (!privateKey) return null;
if (!isKeyEncrypted(privateKey)) {
const resolved = resolveKeyForAuth(privateKey, undefined);
if (!resolved) throw createInvalidPrivateKeyError(keyPath, keyName);
return { privateKey: resolved.privateKey, keyPath, keyName };
}
const promptKeyPath = keyPath || `SSH key for ${keyName || hostname || "connection"}`;
const promptKeyName = keyName || path.basename(promptKeyPath);
let passphraseInvalid = false;
if (initialPassphrase) {
const resolved = resolveKeyForAuth(privateKey, initialPassphrase);
if (resolved) {
return { privateKey: resolved.privateKey, keyPath, keyName, passphrase: resolved.passphrase };
}
console.log(`${logPrefix} Stored passphrase failed for private key`, { keyPath: promptKeyPath });
notifyPassphraseAuthFailed(sender, promptKeyPath, undefined, keyId ? [keyId] : undefined);
passphraseInvalid = true;
}
while (true) {
console.log(`${logPrefix} Private key is encrypted, requesting passphrase`, {
keyPath: promptKeyPath,
passphraseInvalid,
});
onPassphrasePromptShown?.();
const result = await passphraseHandler.requestPassphrase(
sender,
promptKeyPath,
promptKeyName,
hostname,
passphraseInvalid,
{
signal: passphraseSignal,
sessionId,
bootEpoch,
}
);
onPassphrasePromptResolved?.();
if (result?.cancelled) {
throw new PassphraseCancelledError(promptKeyPath);
}
if (!result?.passphrase) {
return null;
}
const resolved = resolveKeyForAuth(privateKey, result.passphrase);
if (resolved) {
return { privateKey: resolved.privateKey, keyPath, keyName, passphrase: resolved.passphrase };
}
console.log(`${logPrefix} Entered passphrase failed for private key`, { keyPath: promptKeyPath });
notifyPassphraseAuthFailed(sender, promptKeyPath, undefined, keyId ? [keyId] : undefined);
passphraseInvalid = true;
}
}
async function loadIdentityFileForAuth({
sender,
keyPath,
hostname,
initialPassphrase,
passphraseSignal,
sessionId,
bootEpoch,
logPrefix = "[SSHAuth]",
onPassphrasePromptShown,
onPassphrasePromptResolved,
}) {
const resolvedPath = expandIdentityFilePath(keyPath);
const privateKey = await fs.promises.readFile(resolvedPath, "utf8");
const keyName = path.basename(resolvedPath);
if (!isKeyEncrypted(privateKey)) {
const resolved = resolveKeyForAuth(privateKey, undefined);
if (!resolved) throw createInvalidPrivateKeyError(resolvedPath, keyName);
return { privateKey: resolved.privateKey, keyPath: resolvedPath, keyName };
}
let passphraseInvalid = false;
if (initialPassphrase) {
const resolved = resolveKeyForAuth(privateKey, initialPassphrase);
if (resolved) {
return { privateKey: resolved.privateKey, keyPath: resolvedPath, keyName, passphrase: resolved.passphrase };
}
console.log(`${logPrefix} Stored passphrase failed for identity file`, { keyPath: resolvedPath });
notifyPassphraseAuthFailed(sender, keyPath, resolvedPath);
passphraseInvalid = true;
}
while (true) {
console.log(`${logPrefix} Identity file is encrypted, requesting passphrase`, {
keyPath: resolvedPath,
passphraseInvalid,
});
onPassphrasePromptShown?.();
const result = await passphraseHandler.requestPassphrase(
sender,
resolvedPath,
keyName,
hostname,
passphraseInvalid,
{
signal: passphraseSignal,
sessionId,
bootEpoch,
}
);
onPassphrasePromptResolved?.();
if (result?.cancelled) {
throw new PassphraseCancelledError(resolvedPath);
}
if (!result?.passphrase) {
return null;
}
const resolved = resolveKeyForAuth(privateKey, result.passphrase);
if (resolved) {
return { privateKey: resolved.privateKey, keyPath: resolvedPath, keyName, passphrase: resolved.passphrase };
}
console.log(`${logPrefix} Entered passphrase failed for identity file`, { keyPath: resolvedPath });
notifyPassphraseAuthFailed(sender, keyPath, resolvedPath);
passphraseInvalid = true;
}
}
async function loadFirstIdentityFileForAuth({
sender,
identityFilePaths,
hostname,
initialPassphrase,
passphraseSignal,
sessionId,
bootEpoch,
logPrefix = "[SSHAuth]",
onLoaded,
onError,
onPassphrasePromptShown,
onPassphrasePromptResolved,
}) {
if (!Array.isArray(identityFilePaths) || identityFilePaths.length === 0) {
return null;
}
let lastInvalidPrivateKeyError = null;
for (const keyPath of identityFilePaths) {
try {
const identityFile = await loadIdentityFileForAuth({
sender,
keyPath,
hostname,
initialPassphrase,
passphraseSignal,
sessionId,
bootEpoch,
logPrefix,
onPassphrasePromptShown,
onPassphrasePromptResolved,
});
if (!identityFile) {
continue;
}
onLoaded?.(identityFile);
return identityFile;
} catch (err) {
if (isPassphraseCancelledError(err)) {
throw err;
}
onError?.(err, keyPath);
if (err?.code === "ERR_PRIVATE_KEY_INVALID") {
lastInvalidPrivateKeyError = err;
}
}
}
if (lastInvalidPrivateKeyError) throw lastInvalidPrivateKeyError;
return null;
}
/**
* Find default SSH private key from user's ~/.ssh directory
* Skips encrypted keys that require a passphrase
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
*/
async function findDefaultPrivateKey() {
const sshDir = path.join(os.homedir(), ".ssh");
let allNames = [];
try {
const entries = await fs.promises.readdir(sshDir);
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
} catch {
return null;
}
const sorted = orderSshIdentityNames(allNames);
for (const name of sorted) {
const keyPath = path.join(sshDir, name);
try {
const privateKey = await readFileNoFollow(keyPath);
if (!privateKey) continue;
if (!looksLikePrivateKey(privateKey)) continue;
if (isKeyEncrypted(privateKey)) continue;
return { privateKey, keyPath, keyName: name };
} catch {
continue;
}
}
return null;
}
/**
* Find ALL default SSH private keys from user's ~/.ssh directory
* @param {Object} [options]
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>>}
*/
async function findAllDefaultPrivateKeys(options = {}) {
const { includeEncrypted = false } = options;
const sshDir = path.join(os.homedir(), ".ssh");
let allNames = [];
try {
const entries = await fs.promises.readdir(sshDir);
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
} catch {
return [];
}
const sorted = orderSshIdentityNames(allNames);
const promises = sorted.map(async (name) => {
const keyPath = path.join(sshDir, name);
try {
const privateKey = await readFileNoFollow(keyPath);
if (!privateKey) return null;
if (!looksLikePrivateKey(privateKey)) return null;
const encrypted = isKeyEncrypted(privateKey);
if (encrypted && !includeEncrypted) {
return null;
}
return {
privateKey,
keyPath,
keyName: name,
...(includeEncrypted ? { isEncrypted: encrypted } : {})
};
} catch {
return null;
}
});
const results = await Promise.all(promises);
return results.filter(Boolean);
}
const WIN_SSH_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
/**
* Check if a Windows named pipe is connectable.
* fs.statSync is unreliable for named pipes (returns EBUSY even when the
* pipe is usable), so we attempt an actual net.connect() which is the
* authoritative check.
* @param {string} pipePath
* @param {number} [timeoutMs=1000]
* @returns {Promise<boolean>}
*/
function windowsPipeConnectable(pipePath, timeoutMs = 1000) {
const net = require("net");
return new Promise((resolve) => {
const socket = net.connect(pipePath);
let settled = false;
const finish = (ok) => {
if (settled) return;
settled = true;
try { socket.destroy(); } catch {}
resolve(ok);
};
socket.setTimeout(timeoutMs);
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}
function isWindowsNamedPipe(agentPath) {
return /^[/\\][/\\]\.[/\\]pipe[/\\].+/.test(agentPath);
}
function ssh2AgentConnectable(agentPath, options = {}) {
const createAgentImpl = options.createAgentImpl || require("ssh2/lib/agent.js").createAgent;
const timeoutMs = options.timeoutMs ?? 1000;
return new Promise((resolve) => {
let settled = false;
let stream = null;
const finish = (ok) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (stream) {
stream.removeAllListeners();
stream.destroy();
}
resolve(ok);
};
const timer = setTimeout(() => finish(false), timeoutMs);
try {
createAgentImpl(agentPath).getStream((error, agentStream) => {
if (settled) {
agentStream?.destroy?.();
return;
}
if (error || !agentStream) return finish(false);
stream = agentStream;
let response = Buffer.alloc(0);
stream.on("data", (chunk) => {
response = Buffer.concat([response, chunk]);
if (response.length < 5) return;
const payloadLength = response.readUInt32BE(0);
if (payloadLength < 1 || payloadLength > 1024 * 1024) return finish(false);
if (response.length < payloadLength + 4) return;
finish(response[4] === 12 || response[4] === 5);
});
stream.once("error", () => finish(false));
stream.once("end", () => finish(false));
stream.once("close", () => finish(false));
stream.write(Buffer.from([0, 0, 0, 1, 11]));
});
} catch {
finish(false);
}
});
}
function cygwinAgentConnectable(agentPath, options = {}) {
const timeoutMs = options.timeoutMs ?? 1000;
const readFile = options.readFileImpl || require("node:fs").promises.readFile;
const createConnection = options.createConnectionImpl || require("node:net").createConnection;
const descriptorPattern = /^!<socket >(\d+) s ([A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8})/;
const resolveCygwinPath = options.resolveCygwinPathImpl || ((value) => new Promise((resolve, reject) => {
require("node:child_process").execFile(
"cygpath",
["-w", value],
{ timeout: timeoutMs, windowsHide: true },
(error, stdout) => {
const converted = String(stdout || "").trim();
if (error || !converted) reject(error || new Error("cygpath returned an empty path"));
else resolve(converted);
},
);
}));
return new Promise((resolve) => {
let settled = false;
let activeSocket = null;
const finish = (ok) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (activeSocket) {
activeSocket.removeAllListeners();
activeSocket.destroy();
}
resolve(ok);
};
const timer = setTimeout(() => finish(false), timeoutMs);
const negotiate = (port, secret, credentials, keepOpen) => new Promise((resolveNegotiation, rejectNegotiation) => {
const socket = activeSocket = createConnection({ host: "127.0.0.1", port });
let state = "secret";
let response = Buffer.alloc(0);
const fail = (error) => {
socket.removeAllListeners();
socket.destroy();
rejectNegotiation(error instanceof Error ? error : new Error("Cygwin agent negotiation failed"));
};
socket.once("connect", () => socket.write(secret));
socket.once("error", fail);
socket.once("end", () => fail(new Error("Cygwin agent ended during negotiation")));
socket.once("close", () => fail(new Error("Cygwin agent closed during negotiation")));
socket.on("data", (chunk) => {
response = Buffer.concat([response, chunk]);
const needed = state === "secret" ? 16 : 12;
if (response.length < needed) return;
const value = response.subarray(0, needed);
response = response.subarray(needed);
if (state === "secret") {
state = "credentials";
socket.write(credentials);
return;
}
socket.removeAllListeners();
if (!keepOpen) {
socket.destroy();
activeSocket = null;
}
resolveNegotiation({ credentials: value, socket: keepOpen ? socket : null, buffered: response });
});
});
const readDescriptor = async () => {
try {
return await readFile(agentPath, "utf8");
} catch {
const convertedPath = await resolveCygwinPath(agentPath);
return readFile(convertedPath, "utf8");
}
};
readDescriptor().then(async (contents) => {
if (settled) return;
const match = descriptorPattern.exec(String(contents));
if (!match) return finish(false);
const port = Number(match[1]);
const secret = Buffer.from(match[2].replace(/-/g, ""), "hex");
for (let offset = 0; offset < secret.length; offset += 4) {
secret.writeUInt32LE(secret.readUInt32BE(offset), offset);
}
try {
const first = await negotiate(port, secret, Buffer.alloc(12), false);
if (settled) return;
const retryCredentials = Buffer.from(first.credentials);
retryCredentials.writeUInt32LE(options.processId ?? process.pid, 0);
const second = await negotiate(port, secret, retryCredentials, true);
if (settled || !second.socket) return;
activeSocket = second.socket;
let response = second.buffered;
const inspectResponse = () => {
if (response.length < 5) return;
const payloadLength = response.readUInt32BE(0);
if (payloadLength < 1 || payloadLength > 1024 * 1024) return finish(false);
if (response.length < payloadLength + 4) return;
finish(response[4] === 12 || response[4] === 5);
};
activeSocket.on("data", (chunk) => {
response = Buffer.concat([response, chunk]);
inspectResponse();
});
activeSocket.once("error", () => finish(false));
activeSocket.once("end", () => finish(false));
activeSocket.once("close", () => finish(false));
activeSocket.write(Buffer.from([0, 0, 0, 1, 11]));
inspectResponse();
} catch {
finish(false);
}
}).catch(() => finish(false));
});
}
function socketAgentConnectable(agentPath, options = {}) {
const createConnection = options.createConnectionImpl || require("node:net").createConnection;
const timeoutMs = options.timeoutMs ?? 1000;
return new Promise((resolve) => {
let settled = false;
let socket = null;
let response = Buffer.alloc(0);
const finish = (ok) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (socket) {
socket.removeAllListeners();
socket.destroy();
}
resolve(ok);
};
const timer = setTimeout(() => finish(false), timeoutMs);
try {
socket = createConnection(agentPath);
socket.once("connect", () => {
// SSH_AGENTC_REQUEST_IDENTITIES: uint32 payload length + byte 11.
socket.write(Buffer.from([0, 0, 0, 1, 11]));
});
socket.on("data", (chunk) => {
response = Buffer.concat([response, chunk]);
if (response.length < 5) return;
const payloadLength = response.readUInt32BE(0);
if (payloadLength < 1 || payloadLength > 1024 * 1024) return finish(false);
if (response.length < payloadLength + 4) return;
const responseType = response[4];
// SSH_AGENT_IDENTITIES_ANSWER (12), or SSH_AGENT_FAILURE (5).
finish(responseType === 12 || responseType === 5);
});
socket.once("error", () => finish(false));
socket.once("end", () => finish(false));
socket.once("close", () => finish(false));
} catch {
finish(false);
}
});
}
function socketAgentIdentityCount(agentPath, options = {}) {
const createConnection = options.createConnectionImpl || require("node:net").createConnection;
const timeoutMs = options.timeoutMs ?? 1000;
return new Promise((resolve) => {
let settled = false;
let socket = null;
let response = Buffer.alloc(0);
const finish = (count) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (socket) {
socket.removeAllListeners();
socket.destroy();
}
resolve(count);
};
const timer = setTimeout(() => finish(null), timeoutMs);
try {
socket = createConnection(agentPath);
socket.once("connect", () => {
socket.write(Buffer.from([0, 0, 0, 1, 11]));
});
socket.on("data", (chunk) => {
response = Buffer.concat([response, chunk]);
if (response.length < 5) return;
const payloadLength = response.readUInt32BE(0);
if (payloadLength < 1 || payloadLength > 1024 * 1024) return finish(null);
if (response.length < payloadLength + 4) return;
const responseType = response[4];
if (responseType === 5) return finish(0);
if (responseType !== 12 || payloadLength < 5) return finish(null);
finish(response.readUInt32BE(5));
});
socket.once("error", () => finish(null));
socket.once("end", () => finish(null));
socket.once("close", () => finish(null));
} catch {
finish(null);
}
});
}
/**
* Check if an SSH agent is available on Windows.
* Probes the well-known named pipe via net.connect(). This supports any
* agent that provides the pipe — Bitwarden, 1Password, gpg-agent, etc.
* @returns {Promise<boolean>}
*/
function checkWindowsSshAgentRunning() {
if (process.platform !== "win32") {
return Promise.resolve(true);
}
return windowsPipeConnectable(WIN_SSH_AGENT_PIPE);
}
/**
* Get ssh-agent socket path based on platform (synchronous, best-effort)
* @returns {string|null}
*/
function resolveIdentityAgentPath(rawPath, context = {}) {
if (typeof rawPath !== "string" || rawPath.trim() === "") return null;
const value = rawPath.trim().replace(/^["']|["']$/g, "");
if (value.toLowerCase() === "none") return null;
if (value === "SSH_AUTH_SOCK") return process.env.SSH_AUTH_SOCK || null;
const localHostname = context.localHostname || os.hostname();
const hostname = context.hostname || "";
const port = String(context.port || 22);
const username = context.username || "";
const proxyJump = context.proxyJump || "";
const tokenValues = {
"%": "%",
d: os.homedir(),
h: hostname,
i: String(context.uid ?? (typeof process.getuid === "function" ? process.getuid() : "")),
j: proxyJump,
k: context.hostKeyAlias || hostname,
L: context.shortLocalHostname || localHostname.split(".")[0],
l: localHostname,
n: context.originalHostname || hostname,
p: port,
r: username,
u: context.localUsername || os.userInfo().username,
};
tokenValues.C = createHash("sha1")
.update(`${localHostname}${hostname}${port}${username}${proxyJump}`)
.digest("hex");
const expanded = value
.replace(/%([%CdhijkLlnpru])/g, (match, token) => tokenValues[token] ?? match)
.replace(/^~(?=$|[\\/])/, os.homedir())
.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => process.env[name] ?? "")
.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_match, name) => process.env[name] ?? "");
return expanded || null;
}
function getSshAgentSocket(identityAgent, context = {}) {
const configuredSocket = resolveIdentityAgentPath(identityAgent, context);
if (identityAgent && !configuredSocket) return null;
if (process.platform === "win32") {
// On Windows, always return the pipe path; the caller should use
// getAvailableAgentSocket() for a reliable async check.
return configuredSocket || WIN_SSH_AGENT_PIPE;
}
const agentSocket = configuredSocket || process.env.SSH_AUTH_SOCK;
if (!agentSocket) return null;
try {
const stats = fs.statSync(agentSocket);
return typeof stats.isSocket === "function" && stats.isSocket()
? agentSocket
: null;
} catch {
return null;
}
}
/**
* Get ssh-agent socket path with async validation (checks Windows service status)
* @returns {Promise<string|null>}
*/
async function getAvailableAgentSocket(identityAgent, injected = {}) {
const configuredSocket = resolveIdentityAgentPath(identityAgent, injected);
if (identityAgent && !configuredSocket) return null;
const platform = injected.platform || process.platform;
if (platform === "win32") {
const socketPath = configuredSocket || WIN_SSH_AGENT_PIPE;
const running = isWindowsNamedPipe(socketPath)
? await (injected.windowsPipeConnectable || windowsPipeConnectable)(socketPath)
: socketPath === "pageant"
? await (injected.ssh2AgentConnectable || ssh2AgentConnectable)(socketPath)
: await (injected.cygwinAgentConnectable || cygwinAgentConnectable)(socketPath);
return running ? socketPath : null;
}
const socketPath = getSshAgentSocket(configuredSocket, injected);
if (!socketPath) return null;
const running = await (injected.socketAgentConnectable || socketAgentConnectable)(socketPath);
return running ? socketPath : null;
}
async function getAvailableForwardingAgentSocket(identityAgent, injected = {}) {
const resolveAvailable = injected.getAvailableAgentSocketImpl || getAvailableAgentSocket;
const platform = injected.platform || process.platform;
const forwardingIdentityAgent = typeof identityAgent === "string"
&& identityAgent.trim().replace(/^["']|["']$/g, "").trim().toLowerCase() === "none"
? undefined
: identityAgent;
if (forwardingIdentityAgent || platform !== "darwin") {
return resolveAvailable(forwardingIdentityAgent, injected);
}
const env = injected.localEnv || process.env;
const home = injected.homedir || os.homedir();
const inheritedCandidates = [env.SSH_AUTH_SOCK, env.SSH_AUTH_SOCKET].filter(Boolean);
const candidates = [...new Set([
...inheritedCandidates,
path.join(home, ".bitwarden-ssh-agent.sock"),
path.join(home, "Library", "Containers", "com.bitwarden.desktop", "Data", ".bitwarden-ssh-agent.sock"),
])];
const inheritedCandidateSet = new Set(inheritedCandidates);
const countIdentities = injected.socketAgentIdentityCount || socketAgentIdentityCount;
let fallbackSocket = null;
for (const candidate of candidates) {
const socketPath = await resolveAvailable(candidate, injected);
if (!socketPath) continue;
if (inheritedCandidateSet.has(candidate)) {
fallbackSocket ||= socketPath;
}
const identityCount = await countIdentities(socketPath, injected);
if (Number.isInteger(identityCount) && identityCount > 0) {
return socketPath;
}
}
return fallbackSocket;
}
async function getNativeOpenSshAgentSocket(identityAgent, injected = {}) {
const socketPath = await getAvailableAgentSocket(identityAgent, injected);
const platform = injected.platform || process.platform;
if (platform === "win32" && socketPath && !isWindowsNamedPipe(socketPath)) {
const error = new Error(
"This SSH agent is available only to Netcatty's built-in SSH client. Mosh and EternalTerminal require a Windows named-pipe agent.",
);
error.code = "ERR_SSH_AGENT_NATIVE_UNSUPPORTED";
throw error;
}
return socketPath;
}
async function getNativeOpenSshForwardingAgentSocket(identityAgent, injected = {}) {
const socketPath = await getAvailableForwardingAgentSocket(identityAgent, injected);
const platform = injected.platform || process.platform;
if (platform === "win32" && socketPath && !isWindowsNamedPipe(socketPath)) {
const error = new Error(
"This SSH agent is available only to Netcatty's built-in SSH client. Mosh and EternalTerminal require a Windows named-pipe agent.",
);
error.code = "ERR_SSH_AGENT_NATIVE_UNSUPPORTED";
throw error;
}
return socketPath;
}
async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
if (options?.useSshAgent !== true) return null;
const socketPath = await getAvailableAgentSocket(options.identityAgent, {
hostname: options.hostname,
port: options.port,
username: options.username,
});
if (!socketPath) {
const error = new Error("System SSH agent is unavailable. Start or unlock it, or configure a valid agent socket.");
error.code = "ERR_SSH_AGENT_UNAVAILABLE";
throw error;
}
return prepareSystemSshAgent({
socketPath,
identityFilePaths: options.identityFilePaths,
identitiesOnly: options.identitiesOnly,
addKeysToAgent: options.addKeysToAgent,
useKeychain: options.useKeychain,
agentPublicKeys: options.agentPublicKeys,
hostname: options.hostname,
port: options.port,
username: options.username,
}, {
log: (message, details) => console.log(`${logPrefix} ${message}`, details ?? ""),
});
}
/**
* True when the session options carry an explicit user key choice — either
* inline private key material or identity file paths from a Keychain reference
* key (issue #1614).
* @param {Object} options
* @param {string} [options.privateKey]
* @param {string[]} [options.identityFilePaths]
* @returns {boolean}
*/
function hasUserConfiguredKey(options) {
if (typeof options?.privateKey === "string" && options.privateKey.trim().length > 0) {
return true;
}
return Array.isArray(options?.identityFilePaths) && options.identityFilePaths.length > 0;
}
/**
* True when a password string should be attached to SSH connect options.
* Whitespace-only passwords are valid SSH secrets (issue #2036) — do not trim.
* @param {unknown} password
* @returns {boolean}
*/
function isPasswordProvided(password) {
return typeof password === "string" && password.length > 0;
}
/**
* Build authentication handler with default key fallback support
* @param {Object} options
* @param {string} [options.privateKey] - Explicitly configured private key
* @param {string} [options.password] - Password for authentication
* @param {string} [options.passphrase] - Passphrase for encrypted private key
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
* @param {string} options.username - SSH username
* @param {string} [options.logPrefix] - Log prefix for debugging
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
*/
/**
* Append the saved-password methods. MFA/PAM hosts can require the login
* password to be submitted through keyboard-interactive and may withdraw that
* method after a rejected plain-password request, so those hosts put KI first.
* Other hosts retain the established password-first order.
*
* skipPasswordMethod (2FA_RETRY): omit the password method so the full
* conversation happens over keyboard-interactive; the saved host password can
* still autofill the first password-looking KI prompt.
*
* @param {Array} authMethods
* @param {{ hasPassword: boolean, skipPasswordMethod?: boolean, preferKeyboardInteractive?: boolean, asObjects?: boolean }} opts
*/
function appendPasswordAuthMethods(authMethods, {
hasPassword,
skipPasswordMethod = false,
preferKeyboardInteractive = false,
asObjects = false,
} = {}) {
if (skipPasswordMethod) return;
if (preferKeyboardInteractive) {
if (asObjects) {
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
} else {
authMethods.push("keyboard-interactive");
}
}
if (!hasPassword) return;
if (asObjects) {
authMethods.push({ type: "password", id: "password" });
} else {
authMethods.push("password");
}
}
function ensureKeyboardInteractiveMethod(authMethods, { asObjects = false } = {}) {
if (asObjects) {
if (!authMethods.some((method) => method.type === "keyboard-interactive")) {
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
}
return;
}
if (!authMethods.includes("keyboard-interactive")) {
authMethods.push("keyboard-interactive");
}
}
function buildAuthHandler(options) {
const {
privateKey,
password,
passphrase,
agent,
username,
logPrefix = "[SSH]",
unlockedEncryptedKeys = [],
defaultKeys = [],
sshAgentSocketOverride,
onAuthAttempt,
skipPasswordMethod = false,
requiresMfa = false,
} = options;
// Determine what type of explicit auth the user configured
const hasExplicitKey = !!privateKey;
const hasExplicitPassword = !!password;
const hasExplicitAgent = !!agent;
const hasExplicitAuth = hasExplicitKey || hasExplicitPassword || hasExplicitAgent;
const isAutomatic = options.authMethod === "auto";
const isExplicitPasswordMode = options.authMethod === "password";
const isSelectedKeyMode = options.authMethod === "key" || options.authMethod === "certificate";
const eligibleUnlockedEncryptedKeys = isExplicitPasswordMode || isSelectedKeyMode
? []
: unlockedEncryptedKeys;
// Determine if this is a password-only or key-only connection
const isPasswordOnly = isExplicitPasswordMode
|| (!isAutomatic && hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent);
const isKeyOnly = !isPasswordOnly && hasExplicitKey && !hasExplicitAgent;
const allowAgentFallback = options.allowAgentFallback !== false
&& !isPasswordOnly
&& !isSelectedKeyMode;
// Allow callers to pass in a pre-validated agent socket (e.g. from async
// getAvailableAgentSocket). Fall back to synchronous getSshAgentSocket()
// which on Windows always returns the pipe path without checking the service.
const sshAgentSocket = allowAgentFallback
? (sshAgentSocketOverride !== undefined ? sshAgentSocketOverride : getSshAgentSocket())
: null;
// Only use system ssh-agent BEFORE user's auth when:
// - User explicitly configured agent, OR
// - No explicit auth is configured (pure fallback mode)
// When user configured key/password, system agent should only be used AFTER as fallback
const useAgentFirst = hasExplicitAgent || isAutomatic || !hasExplicitAuth;
// Determine effective agent
const effectiveAgent = isPasswordOnly ? null : agent || (useAgentFirst ? sshAgentSocket : null);
// Determine effective privateKey (user-provided takes priority). Explicit
// key/certificate selection must fail closed when the selected material is
// unavailable instead of silently substituting an unrelated default key.
const mayUseDefaultKeyAsPrimary = isAutomatic || (!isSelectedKeyMode && !hasExplicitAuth);
const effectivePrivateKey = isPasswordOnly
? null
: privateKey || (mayUseDefaultKeyAsPrimary && defaultKeys.length > 0 ? defaultKeys[0].privateKey : null);
// Determine fallback keys (keys to try after user's primary auth fails)
// - If user provided a key: all default keys are fallbacks
// - If no explicit auth: first default key is primary, rest are fallbacks
// - If password-only: no default-key fallback (issue #266 / #2079)
// - If agent-only: all default keys are fallbacks (tried after primary)
const fallbackKeys = isPasswordOnly || isSelectedKeyMode
? []
: isAutomatic
? defaultKeys
: hasExplicitKey
? defaultKeys
: !hasExplicitAuth
? defaultKeys.slice(1)
: defaultKeys;
// Check if we need dynamic handler (have fallback options).
// Password-only never treats default keys as fallbacks — only unlocked
// encrypted keys (jump-chain retry) and keyboard-interactive remain.
// Callers that pass onAuthAttempt still get progress callbacks on the
// simple ordered path (createOrderedStringAuthHandler) so jump/SFTP UIs
// keep showing auth attempts after #2079 (Codex review P2).
const hasFallbackOptions = fallbackKeys.length > 0 ||
(!hasExplicitAgent && !isPasswordOnly && sshAgentSocket) ||
eligibleUnlockedEncryptedKeys.length > 0;
// Simple explicit auth with no fallback keys: preserve the old ordered
// method list, but use a function handler so we can observe partialSuccess
// for second-factor keyboard-interactive (#2150). Plain string arrays hide
// that signal from us and leave authPhase stuck at false.
if (hasExplicitAuth && !hasFallbackOptions) {
const authMethods = ["none"]; // Always try none first per RFC 4252
if (effectiveAgent) authMethods.push("agent");
if (!isPasswordOnly && privateKey) authMethods.push("publickey");
appendPasswordAuthMethods(authMethods, {
hasPassword: !!password,
skipPasswordMethod,
preferKeyboardInteractive: requiresMfa,
asObjects: false,
});
ensureKeyboardInteractiveMethod(authMethods, { asObjects: false });
const authPhase = createAuthPhase();
return {
authHandler: createOrderedStringAuthHandler(authMethods, authPhase, onAuthAttempt),
privateKey: effectivePrivateKey,
agent: effectiveAgent,
usedDefaultKeys: false,
authPhase,
};
}
// Build comprehensive authMethods array with all auth options
// Order depends on what user explicitly configured. requiresMfa moves
// keyboard-interactive ahead of password, while an explicitly selected key,
// certificate, or agent remains the first factor.
const authMethods = [];
if (isPasswordOnly) {
// Password-only: respect user's explicit choice, no key/agent fallback.
// Matches startSSHSession (issue #2079) and avoids #266 passphrase prompts.
appendPasswordAuthMethods(authMethods, {
hasPassword: !!password,
skipPasswordMethod,
preferKeyboardInteractive: requiresMfa,
asObjects: true,
});
} else if (isAutomatic) {
// Automatic normally mirrors the familiar OpenSSH-style order. With MFA
// enabled and no saved password, prompt before probing discovered keys so
// repeated key attempts cannot exhaust a server's authentication limit.
if (requiresMfa && !hasExplicitPassword) {
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
}
if (effectiveAgent) {
authMethods.push({ type: "agent", id: "agent" });
}
if (privateKey) {
authMethods.push({
type: "publickey",
key: privateKey,
passphrase,
id: "publickey-user"
});
}
for (const keyInfo of fallbackKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
});
}
appendPasswordAuthMethods(authMethods, {
hasPassword: !!password,
skipPasswordMethod,
preferKeyboardInteractive: requiresMfa && hasExplicitPassword,
asObjects: true,
});
} else if (isKeyOnly) {
// Key-only: user key first, then password (if any), then agent/default keys as fallback
// 1. User-provided key first
authMethods.push({
type: "publickey",
key: privateKey,
passphrase: passphrase,
id: "publickey-user"
});
// 2. Password (if configured alongside key)
appendPasswordAuthMethods(authMethods, {
hasPassword: !!password,
skipPasswordMethod,
preferKeyboardInteractive: requiresMfa,
asObjects: true,
});
// 3. System agent as fallback (AFTER user's key)
if (sshAgentSocket) {
authMethods.push({ type: "agent", id: "agent" });
}
// 4. Default keys as fallback
for (const keyInfo of fallbackKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
});
}
} else {
// Agent configured or no explicit auth: agent -> user key -> password -> default keys
// 1. Agent (user-provided or system)
if (effectiveAgent) {
authMethods.push({ type: "agent", id: "agent" });
}
// 2. User-provided key
if (privateKey) {
authMethods.push({
type: "publickey",
key: privateKey,
passphrase: passphrase,
id: "publickey-user"
});
}
// 3. Password (if configured)
appendPasswordAuthMethods(authMethods, {
hasPassword: !!password,
skipPasswordMethod,
preferKeyboardInteractive: requiresMfa,
asObjects: true,
});
// 4. Default keys as fallback
for (const keyInfo of fallbackKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
id: `publickey-default-${keyInfo.keyName}`
});
}
// 5. If no user key provided, add first default key at the beginning (after agent)
if (!isSelectedKeyMode && !privateKey && defaultKeys.length > 0) {
const insertIndex = effectiveAgent ? 1 : 0;
authMethods.splice(insertIndex, 0, {
type: "publickey",
key: defaultKeys[0].privateKey,
id: `publickey-default-${defaultKeys[0].keyName}`
});
}
}
// Add unlocked encrypted default keys (user provided passphrases for these)
for (const keyInfo of eligibleUnlockedEncryptedKeys) {
authMethods.push({
type: "publickey",
key: keyInfo.privateKey,
passphrase: keyInfo.passphrase,
id: `publickey-encrypted-${keyInfo.keyName}`
});
}
// Keyboard-interactive as last-resort fallback for PAM/EDR multi-round auth.
ensureKeyboardInteractiveMethod(authMethods, { asObjects: true });
console.log(`${logPrefix} Auth methods configured`, {
isAutomatic,
isPasswordOnly,
hasUserKey: !!privateKey,
hasPassword: !!password,
hasAgent: !!effectiveAgent,
skipPasswordMethod: !!skipPasswordMethod,
methodCount: authMethods.length,
methods: authMethods.map(m => m.id),
});
// Use dynamic authHandler to try all keys
let lastAttemptedLabel = null;
const attemptedMethodIds = new Set();
const succeededMethodIds = new Set();
const failedMethodIds = new Set();
// Shared with keyboard-interactive auto-fill. See createAuthPhase /
// shouldSkipKiPasswordAutoFill — a completed password or keyboard-
// interactive factor suppresses reusing the saved host password on a later
// KI challenge (#2150 / #2151).
const authPhase = createAuthPhase();
let lastAttemptedType = null;
let lastAttemptedMethodId = null;
let passwordAttemptSawKeyboardInteractive = false;
let triedNone = false;
const authHandler = (methodsLeft, partialSuccess, callback) => {
// Per RFC 4252, always try "none" first to discover available methods
// and to support passwordless login (e.g. embedded devices).
// This matches the behavior of OpenSSH and Tabby.
if (methodsLeft === null && !triedNone) {
triedNone = true;
lastAttemptedLabel = "none (no credentials)";
lastAttemptedType = "none";
lastAttemptedMethodId = null;
onAuthAttempt?.("none (no credentials)");
return callback("none");
}
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
// Log rejection of previous method (authHandler is called again when server rejects)
if (lastAttemptedLabel && !partialSuccess) {
onAuthAttempt?.(`${lastAttemptedLabel} rejected`);
if (
lastAttemptedType === "password" &&
passwordAttemptSawKeyboardInteractive &&
Array.isArray(methodsLeft) &&
!methodsLeft.includes("keyboard-interactive")
) {
authPhase.retryKeyboardInteractiveFirst = true;
}
if (lastAttemptedMethodId) {
failedMethodIds.add(lastAttemptedMethodId);
}
}
if (partialSuccess) {
markAuthPhasePartialSuccess(authPhase, lastAttemptedType);
if (lastAttemptedMethodId) {
succeededMethodIds.add(lastAttemptedMethodId);
}
// Start a fresh pass for the next factor. Methods merely unavailable in
// the previous phase become eligible again, while methods actually
// rejected or already successful stay suppressed.
attemptedMethodIds.clear();
for (const methodId of failedMethodIds) {
attemptedMethodIds.add(methodId);
}
for (const methodId of succeededMethodIds) {
attemptedMethodIds.add(methodId);
}
// Some PAM/EDR stacks model login-password + secondary-password as two
// consecutive keyboard-interactive authentication factors. The server
// reports the first factor as partial success and advertises the same
// method again for the second factor. Unlike a key, keyboard-interactive
// is safe to repeat because the server supplies a fresh challenge and
// createKeyboardInteractiveHandler refuses to auto-fill after round one.
if (
Array.isArray(methodsLeft) &&
methodsLeft.includes("keyboard-interactive") &&
canRepeatKeyboardInteractive(authPhase, failedMethodIds)
) {
attemptedMethodIds.delete("keyboard-interactive");
}
}
// After any successful factor, prefer keyboard-interactive for the next
// factor when the server still advertises it. Password-first remains for
// the first factor; this is automatic KI fallback without a host switch.
if (
authPhase.hadPartialSuccess
&& availableMethods.includes("keyboard-interactive")
&& !attemptedMethodIds.has("keyboard-interactive")
) {
const kiMethod = authMethods.find((method) => method.type === "keyboard-interactive");
if (kiMethod) {
attemptedMethodIds.add(kiMethod.id);
lastAttemptedLabel = "keyboard-interactive";
lastAttemptedType = "keyboard-interactive";
lastAttemptedMethodId = kiMethod.id;
onAuthAttempt?.("keyboard-interactive");
return callback("keyboard-interactive");
}
}
for (const method of authMethods) {
if (attemptedMethodIds.has(method.id)) continue;
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
attemptedMethodIds.add(method.id);
console.log(`${logPrefix} Trying agent auth`);
lastAttemptedLabel = "SSH agent";
lastAttemptedType = "agent";
lastAttemptedMethodId = method.id;
onAuthAttempt?.("SSH agent");
return callback("agent");
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
attemptedMethodIds.add(method.id);
console.log(`${logPrefix} Trying publickey auth:`, method.id);
// Build a readable label for the key
const keyLabel = method.id.startsWith("publickey-default-")
? `key ${method.id.replace("publickey-default-", "")}`
: method.id.startsWith("publickey-encrypted-")
? `key ${method.id.replace("publickey-encrypted-", "")} (encrypted)`
: method.id === "publickey-user"
? "configured key"
: method.id;
lastAttemptedLabel = keyLabel;
lastAttemptedType = "publickey";
lastAttemptedMethodId = method.id;
onAuthAttempt?.(keyLabel);
const pubkeyAuth = {
type: "publickey",
username,
key: method.key,
};
if (method.passphrase) {
pubkeyAuth.passphrase = method.passphrase;
}
return callback(pubkeyAuth);
} else if (method.type === "password" && availableMethods.includes("password")) {
attemptedMethodIds.add(method.id);
console.log(`${logPrefix} Trying password auth`);
lastAttemptedLabel = "password";
lastAttemptedType = "password";
lastAttemptedMethodId = method.id;
passwordAttemptSawKeyboardInteractive = availableMethods.includes("keyboard-interactive");
onAuthAttempt?.("password");
return callback({
type: "password",
username,
password,
});
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
attemptedMethodIds.add(method.id);
lastAttemptedLabel = "keyboard-interactive";
lastAttemptedType = "keyboard-interactive";
lastAttemptedMethodId = method.id;
onAuthAttempt?.("keyboard-interactive");
return callback("keyboard-interactive");
}
}
onAuthAttempt?.("all methods exhausted");
return callback(false);
};
// Determine the agent to return - if authMethods includes agent, we need to provide the socket
// even if effectiveAgent is null (for fallback scenarios)
const hasAgentInMethods = authMethods.some(m => m.type === "agent");
const returnAgent = effectiveAgent || (hasAgentInMethods ? sshAgentSocket : null);
return {
authHandler,
privateKey: effectivePrivateKey,
agent: returnAgent,
usedDefaultKeys: true,
authPhase,
};
}
// OTP / MFA / step-up 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", and
// EDR/secondary-password prompts like "二次密码" / "Secondary password"
// (issue #2150). Submitting the saved login password into any of these
// burns an auth attempt and often disconnects without re-prompting.
// (#969 PR review, second round; #2150.)
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",
// Step-up / secondary password (login password already used as first factor).
// Allow a few words between "secondary" and "password" so prompts like
// "Secondary Authentication Password:" (common EDR English label) match.
"secondary(?:\\s+\\w+){0,3}\\s+passw",
"second(?:\\s+\\w+){0,3}\\s+passw",
"additional(?:\\s+\\w+){0,3}\\s+passw",
"re[-\\s]?enter\\s+passw",
"confirm\\s+passw",
"\\bedr\\b",
"duo",
// CJK — no word boundaries; substring match is intentional
"动态",
"一次性",
"验证码",
"验证信息",
"令牌",
"双因素",
"多因素",
"短信验证",
"手机验证",
// Corporate EDR / bastion second-factor password prompts (#2150)
// Covers "二次密码", "二次认证密码", "二次验证", etc.
"二次",
"安全密码",
"挑战码",
].join("|"),
"i",
);
// Narrower than OTP_PROMPT_PATTERN: used only for suggesting host-level MFA.
// Password-change words such as "confirm password" still block auto-fill via
// OTP_PROMPT_PATTERN, but they must not make a normal host become MFA-first.
const MFA_SUGGESTION_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",
"secondary(?:\\s+\\w+){0,3}\\s+passw",
"second(?:\\s+\\w+){0,3}\\s+passw",
"additional(?:\\s+\\w+){0,3}\\s+passw",
"\\bedr\\b",
"duo",
"动态",
"一次性",
"验证码",
"验证信息",
"令牌",
"双因素",
"多因素",
"短信验证",
"手机验证",
"二次",
"安全密码",
"挑战码",
].join("|"),
"i",
);
// Password-expiry / password-change keyboard-interactive forms are not MFA.
const PASSWORD_CHANGE_PROMPT_PATTERN =
/\b(?:current|old|new)\s+(?:unix\s+)?passw|confirm\s+(?:new\s+)?passw|re[-\s]?enter\s+(?:new\s+)?passw/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;
const MAX_KEYBOARD_INTERACTIVE_FACTORS = 2;
const EDR_SECONDARY_AUTH_FALLBACK_INSTRUCTIONS =
"为保障主机安全请输入二次认证密码如有疑问请联系xxx电话xxx。";
/**
* Shared auth-phase state for keyboard-interactive auto-fill decisions.
* passwordAlreadySucceeded means the password method already contributed a
* successful factor. keyboardInteractiveSuccessCount remembers completed KI
* factors, so a later KI challenge is treated as distinct even when another
* factor ran between them. Neither value changes for publickey/agent alone,
* preserving publickey+password MFA auto-fill.
*/
function createAuthPhase() {
return {
hadPartialSuccess: false,
passwordAlreadySucceeded: false,
keyboardInteractiveSuccessCount: 0,
retryKeyboardInteractiveFirst: false,
};
}
/**
* Record a partialSuccess against authPhase. Pass the method type that just
* succeeded (e.g. "password", "publickey", "agent", or
* "keyboard-interactive").
*/
function markAuthPhasePartialSuccess(authPhase, succeededMethodType) {
if (!authPhase) return;
authPhase.hadPartialSuccess = true;
if (succeededMethodType === "password") {
authPhase.passwordAlreadySucceeded = true;
}
if (succeededMethodType === "keyboard-interactive") {
authPhase.keyboardInteractiveSuccessCount =
(authPhase.keyboardInteractiveSuccessCount || 0) + 1;
}
}
/**
* Allow at most one repeated keyboard-interactive factor. Two completed KI
* factors cover login-password + secondary-password flows while preventing a
* broken or malicious server from keeping the client in an unbounded loop.
* Once an interactive factor is rejected, it must not be revived by a later
* partial success from another method.
* @param {Object} authPhase
* @param {Set<string>} [failedMethodIds]
*/
function canRepeatKeyboardInteractive(authPhase, failedMethodIds) {
if (!authPhase) return false;
if (failedMethodIds?.has("keyboard-interactive")) return false;
return (authPhase.keyboardInteractiveSuccessCount || 0) ===
MAX_KEYBOARD_INTERACTIVE_FACTORS - 1;
}
/**
* Whether keyboard-interactive should refuse to auto-fill / prefill the saved
* host password. A completed password or keyboard-interactive factor means a
* later KI challenge is a distinct factor. publickey→Password: MFA still
* returns false so the saved login password can be used there.
*/
function shouldSkipKiPasswordAutoFill(authPhase) {
return Boolean(
authPhase &&
(
authPhase.passwordAlreadySucceeded ||
(authPhase.keyboardInteractiveSuccessCount || 0) > 0
)
);
}
/**
* Wrap a simple ordered list of ssh2 auth method *strings* (the form used by
* `connectOpts.authHandler = ["none","password","keyboard-interactive"]`)
* so callers can observe `partialSuccess` for multi-factor flows (#2150).
*
* Behavior mirrors ssh2's array handler, with one important difference from a
* naive index cursor: methods that are merely *unavailable on this call*
* (not in `methodsLeft`) are NOT permanently consumed. That matters when a
* server first advertises only `publickey`, then after a partial-success key
* re-advertises `password` — advancing past password on the first pass would
* leave the connection unable to offer the second factor (Codex P2 on #2151).
*
* @param {string[]} order
* @param {{ hadPartialSuccess: boolean, passwordAlreadySucceeded?: boolean, keyboardInteractiveSuccessCount?: number }} authPhase
* @param {(label: string) => void} [onAuthAttempt] - optional progress callback
* (jump/SFTP connection logs). Mirrors the dynamic authHandler's onAuthAttempt.
* @param {{ username?: string, agent?: unknown }} [agentAuth] - optional
* login-only agent override when the connection's agent is reserved for forwarding.
* @returns {(methodsLeft: string[]|null, partialSuccess: boolean, callback: Function) => void}
*/
function createOrderedStringAuthHandler(order, authPhase, onAuthAttempt, agentAuth = {}) {
// Methods we actually offered (server got a chance to accept/reject).
let attempted = new Set();
// Methods that contributed a successful factor; never retried.
const succeeded = new Set();
// Methods actually rejected by the server. Methods that were merely absent
// from an earlier methodsLeft list are intentionally not recorded here.
const failed = new Set();
let lastOffered = null;
let passwordAttemptSawKeyboardInteractive = false;
const attemptLabel = (method) => {
if (method === "none") return "none (no credentials)";
if (method === "agent") return "SSH agent";
return method;
};
const resolveAttempt = (method) => method === "agent" && agentAuth.agent
? {
type: "agent",
username: agentAuth.username,
agent: agentAuth.agent,
}
: method;
return (methodsLeft, partialSuccess, callback) => {
if (partialSuccess) {
markAuthPhasePartialSuccess(authPhase, lastOffered);
if (lastOffered) succeeded.add(lastOffered);
// Reconsider methods that were unavailable in the previous factor, but
// do not resubmit credentials the server already rejected or accepted.
attempted = new Set([...failed, ...succeeded]);
// A server may deliberately require keyboard-interactive more than once
// (for example PAM login password followed by an EDR secondary password).
// Re-offer it only when the server explicitly advertises it for the next
// factor. Failed attempts still remain blocked on non-partial callbacks.
if (
Array.isArray(methodsLeft) &&
methodsLeft.includes("keyboard-interactive") &&
canRepeatKeyboardInteractive(authPhase, failed)
) {
attempted.delete("keyboard-interactive");
}
} else if (lastOffered && methodsLeft !== null) {
// Server rejected the previous method (or finished a failed factor).
// Skip the initial methodsLeft===null probe which is not a rejection.
onAuthAttempt?.(`${attemptLabel(lastOffered)} rejected`);
if (
lastOffered === "password" &&
passwordAttemptSawKeyboardInteractive &&
Array.isArray(methodsLeft) &&
!methodsLeft.includes("keyboard-interactive")
) {
authPhase.retryKeyboardInteractiveFirst = true;
}
failed.add(lastOffered);
}
const available = Array.isArray(methodsLeft) && methodsLeft.length > 0
? methodsLeft
: null;
// After a successful factor, prefer keyboard-interactive when the server
// still allows it (automatic KI fallback; no host MFA switch required).
if (
authPhase.hadPartialSuccess
&& order.includes("keyboard-interactive")
&& !attempted.has("keyboard-interactive")
&& (!available || available.includes("keyboard-interactive"))
) {
attempted.add("keyboard-interactive");
lastOffered = "keyboard-interactive";
onAuthAttempt?.(attemptLabel("keyboard-interactive"));
return callback("keyboard-interactive");
}
for (const method of order) {
if (attempted.has(method)) continue;
if (available) {
const allowed =
available.includes(method) ||
(method === "agent" && available.includes("publickey"));
// Not advertised right now — leave it eligible for a later phase.
if (!allowed) continue;
}
attempted.add(method);
lastOffered = method;
if (method === "password") {
passwordAttemptSawKeyboardInteractive = Boolean(
available && available.includes("keyboard-interactive"),
);
}
onAuthAttempt?.(attemptLabel(method));
return callback(resolveAttempt(method));
}
onAuthAttempt?.("all methods exhausted");
return callback(false);
};
}
/**
* 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:
* - exactly one prompt (multi-prompt is almost certainly real 2FA / MFA)
* - the prompt has `echo === false`
* - the prompt text + optional name/instructions do NOT contain any OTP /
* MFA / secondary-password vocabulary (EDR often puts the Chinese
* "二次认证密码" wording in instructions and only "Secondary
* Authentication Password:" in the prompt field — see #2150)
* - 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.
*
* @param {Array} prompts
* @param {string} password
* @param {string} [contextText] - name + instructions from the KI challenge
*/
function isAutoFillablePasswordChallenge(prompts, password, contextText = "") {
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 : "";
// Scan prompt + name/instructions together so secondary-password wording
// that only appears in the instruction banner still blocks auto-fill.
const haystack = [contextText, promptText].filter(Boolean).join("\n");
if (OTP_PROMPT_PATTERN.test(haystack)) return false;
return PASSWORD_PROMPT_PATTERN.test(promptText);
}
/**
* Whether the modal may pre-fill / offer-to-save the host login password for
* this challenge. Single secondary/EDR prompts and post-partialSuccess
* challenges must open empty (#2150). Multi-prompt forms (password + OTP)
* still get the saved value as a convenience for the password slot.
*
* @param {Array} prompts
* @param {string} password
* @param {{ skipAutoFill?: boolean, contextText?: string }} [opts]
*/
function shouldPrefillSavedPassword(prompts, password, { skipAutoFill = false, contextText = "" } = {}) {
if (skipAutoFill) return false;
if (typeof password !== "string" || password.length === 0) return false;
if (!Array.isArray(prompts) || prompts.length === 0) return false;
// Single-prompt: only prefill classic first-factor password challenges.
// Secondary/OTP wording in name/instructions still blocks via contextText.
if (prompts.length === 1) {
return isAutoFillablePasswordChallenge(prompts, password, contextText);
}
// Multi-prompt (e.g. Password: + Verification code: / Duo): always prefill
// the password slot when we have a saved host password. Challenge names like
// "Duo two-factor" must NOT suppress prefill — skipAutoFill already covers
// true post-partialSuccess second-factor rounds (Codex P3 on #2151).
// The modal only writes into isAPasswordPrompt fields, not OTP slots.
return true;
}
function getFallbackKeyboardInteractiveInstructions(prompts) {
if (!Array.isArray(prompts) || prompts.length !== 1) return "";
const prompt = typeof prompts[0]?.prompt === "string" ? prompts[0].prompt : "";
return /secondary\s+authentication\s+password/i.test(prompt)
? EDR_SECONDARY_AUTH_FALLBACK_INSTRUCTIONS
: "";
}
/**
* Whether a keyboard-interactive challenge looks like a secondary / EDR /
* OTP factor (not the ordinary first-factor host password). Used for modal
* copy and optional "enable MFA mode on this host" suggestions.
*/
function looksLikeSecondaryAuthChallenge(name, instructions, prompts, extraContext = "") {
const parts = [name, instructions, extraContext];
if (Array.isArray(prompts)) {
for (const prompt of prompts) {
if (prompt && typeof prompt.prompt === "string") parts.push(prompt.prompt);
}
}
const haystack = parts.filter((part) => typeof part === "string" && part.trim()).join("\n");
if (!haystack) return false;
if (PASSWORD_CHANGE_PROMPT_PATTERN.test(haystack)) return false;
return MFA_SUGGESTION_PROMPT_PATTERN.test(haystack)
|| /secondary\s+authentication\s+password/i.test(haystack);
}
/**
* 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.hostId] - Owning vault host ID, when known.
* @param {string} options.hostname - Host being connected to
* @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 {"terminal"|"external"} [options.scope] - Renderer-side routing scope
* @param {Function} [options.shouldSkipAutoFill] - Optional predicate. When it
* returns true, never auto-fill — always show the modal. Callers set this
* after a first-factor `partialSuccess` so a second-factor challenge that
* merely says "Password:" / "密码:" is not silently answered with the
* already-used login password (#2150).
* @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).
* @param {Function} [options.getAuthBanner] - Returns the most recent SSH
* USERAUTH_BANNER text for this connection, if the server sent one before
* the keyboard-interactive challenge.
* @returns {Function} - Event handler for 'keyboard-interactive' event
*/
function createKeyboardInteractiveHandler(options) {
const {
sender,
sessionId,
hostId,
hostname,
password,
logPrefix = "[SSH]",
scope = "external",
bootEpoch,
shouldSkipAutoFill,
onAutoFill,
onPromptShown,
onUserResponded,
getAuthBanner,
} = 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`, {
name,
instructions,
promptCount: prompts?.length || 0,
});
// 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;
}
let authBanner = "";
try {
authBanner = typeof getAuthBanner === "function" ? String(getAuthBanner() || "").trim() : "";
} catch (err) {
console.warn(`${logPrefix} getAuthBanner callback threw`, err);
}
const fallbackInstructions = getFallbackKeyboardInteractiveInstructions(prompts);
const modalInstructions = instructions || authBanner || fallbackInstructions;
// name + keyboard-interactive instructions often carry the real EDR
// warning (e.g. "请输入二次认证密码") while prompts[i].prompt is only the
// short English field label. USERAUTH_BANNER is intentionally display-only:
// it can be a generic corporate legal/MFA banner before an ordinary first
// factor Password: prompt.
const contextText = [name, instructions, fallbackInstructions]
.filter((s) => typeof s === "string" && s.trim())
.join("\n");
let skipAutoFill = false;
try {
skipAutoFill = typeof shouldSkipAutoFill === "function" && !!shouldSkipAutoFill();
} catch (err) {
console.warn(`${logPrefix} shouldSkipAutoFill callback threw`, err);
}
const autoFillablePasswordChallenge =
isAutoFillablePasswordChallenge(prompts, password, contextText);
// After a first factor already succeeded (partialSuccess), never reuse the
// saved login password for a later keyboard-interactive challenge — even
// if the prompt text looks like a plain "Password:" / "密码:". Corporate
// EDR step-up often reuses password wording for a different secret.
if (
!skipAutoFill &&
!autoFilledOnce &&
autoFillablePasswordChallenge
) {
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, sender);
const promptsData = prompts.map((p) => ({
prompt: p.prompt,
echo: p.echo,
}));
// Never prefill the host login password into a second-factor challenge or
// into a retry after a failed auto-fill. Passing null here is what keeps
// KeyboardInteractiveModal from re-submitting the wrong secret on Enter
// (#2150). autoFilledOnce blocks prefill only — not the save checkbox.
const savedPasswordForModal = shouldPrefillSavedPassword(prompts, password, {
skipAutoFill: skipAutoFill || autoFilledOnce,
contextText,
})
? password
: null;
// Hide "Save password" only for true second-factor challenges:
// - after a first-factor partialSuccess, or
// - a *single* OTP / EDR secondary field (possibly with secondary wording
// only in name/instructions).
// Do NOT disable save after a failed auto-fill retry of the same first-
// factor Password: prompt — the user may have corrected a stale login
// password and should be able to persist it (Codex P2 on #2151).
// Do NOT disable save just because a multi-prompt challenge also includes
// an OTP field next to Password: (PAM/Duo first-login) — the modal only
// ever saves the isAPasswordPrompt slot (Codex P3 on #2151).
const singlePromptText =
prompts.length === 1 && typeof prompts[0]?.prompt === "string"
? prompts[0].prompt
: "";
const singleSecondaryChallenge =
prompts.length === 1 &&
OTP_PROMPT_PATTERN.test(
[contextText, singlePromptText].filter(Boolean).join("\n"),
);
const allowSavePassword = !(skipAutoFill || singleSecondaryChallenge);
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,
sessionId,
hostId,
name: name || hostname,
instructions: modalInstructions,
prompts: promptsData,
hostname: hostname,
savedPassword: savedPasswordForModal,
allowSavePassword,
scope,
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
});
};
}
const { safeSend } = require("./ipcUtils.cjs");
/**
* Apply auth configuration to connection options
* Convenience function that combines buildAuthHandler results with connOpts
* @param {Object} connOpts - SSH connection options to modify
* @param {Object} authConfig - Auth configuration from buildAuthHandler
*/
function applyAuthToConnOpts(connOpts, authConfig) {
connOpts.authHandler = authConfig.authHandler;
if (authConfig.privateKey) {
connOpts.privateKey = authConfig.privateKey;
}
if (authConfig.agent) {
connOpts.agent = authConfig.agent;
}
}
/**
* Request passphrases for encrypted default keys
* Shows a modal for each encrypted key and collects passphrases
* @param {Object} sender - Electron webContents sender
* @param {string} [hostname] - Optional hostname for context
* @returns {Promise<{ keys: Array<{ privateKey: string, keyPath: string, keyName: string, passphrase: string }>, cancelled: boolean }>}
*/
async function requestPassphrasesForEncryptedKeys(sender, hostname, options = {}) {
const allKeys = await findAllDefaultPrivateKeys({ includeEncrypted: true });
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
if (encryptedKeys.length === 0) {
return { keys: [], cancelled: false };
}
console.log(`[SSHAuth] Found ${encryptedKeys.length} encrypted default key(s), requesting passphrases`);
const unlockedKeys = [];
let wasCancelled = false;
for (const keyInfo of encryptedKeys) {
const result = await passphraseHandler.requestPassphrase(
sender,
keyInfo.keyPath,
keyInfo.keyName,
hostname,
false,
{
signal: options.signal,
sessionId: options.sessionId,
bootEpoch: options.bootEpoch,
},
);
// Handle different response types
if (!result) {
// Timeout or error - continue with next key
console.log(`[SSHAuth] No response for ${keyInfo.keyName}, continuing...`);
continue;
}
if (result.cancelled) {
// User clicked Cancel - stop the entire flow
console.log(`[SSHAuth] User cancelled passphrase flow at ${keyInfo.keyName}`);
wasCancelled = true;
break;
}
if (result.skipped) {
// User clicked Skip - continue with next key
console.log(`[SSHAuth] User skipped passphrase for ${keyInfo.keyName}`);
continue;
}
if (result.passphrase) {
// User provided passphrase
unlockedKeys.push({
privateKey: keyInfo.privateKey,
keyPath: keyInfo.keyPath,
keyName: keyInfo.keyName,
passphrase: result.passphrase,
});
}
}
return { keys: unlockedKeys, cancelled: wasCancelled };
}
module.exports = {
PREFERRED_KEY_NAMES,
SSH_KEY_PATTERN,
orderSshIdentityNames,
looksLikePrivateKey,
isKeyEncrypted,
findDefaultPrivateKey,
findAllDefaultPrivateKeys,
getSshAgentSocket,
getAvailableAgentSocket,
getAvailableForwardingAgentSocket,
getNativeOpenSshAgentSocket,
getNativeOpenSshForwardingAgentSocket,
isWindowsNamedPipe,
ssh2AgentConnectable,
cygwinAgentConnectable,
socketAgentConnectable,
socketAgentIdentityCount,
resolveIdentityAgentPath,
prepareSystemSshAgentForAuth,
buildAuthHandler,
appendPasswordAuthMethods,
ensureKeyboardInteractiveMethod,
createAuthPhase,
markAuthPhasePartialSuccess,
canRepeatKeyboardInteractive,
shouldSkipKiPasswordAutoFill,
createOrderedStringAuthHandler,
createKeyboardInteractiveHandler,
isAutoFillablePasswordChallenge,
shouldPrefillSavedPassword,
looksLikeSecondaryAuthChallenge,
applyAuthToConnOpts,
safeSend,
requestPassphrasesForEncryptedKeys,
readFileNoFollow,
expandIdentityFilePath,
preparePrivateKeyForAuth,
loadIdentityFileForAuth,
loadFirstIdentityFileForAuth,
hasUserConfiguredKey,
isPasswordProvided,
PassphraseCancelledError,
isPassphraseCancelledError,
};