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
2125 lines
78 KiB
JavaScript
2125 lines
78 KiB
JavaScript
/**
|
||
* 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,
|
||
};
|