Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions components/KeyboardInteractiveModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,50 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
const [showPasswords, setShowPasswords] = useState<boolean[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);

const getAutofillPasswordIndex = useCallback((prompts: KeyboardInteractivePrompt[]) => {
const passwordPromptPattern = /password|passphrase|passwd|密码|口令/i;
const oneTimeCodePattern = /otp|token|verification|verify|\bcode\b|验证码|动态码|二次验证|双因素|2fa|mfa/i;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat one-time password prompts as OTP, not saved password

The OTP guard regex misses common prompt text like "One-time password:", so getAutofillPasswordIndex() will classify it as a normal password prompt (because it contains password) and buildAutofilledResponses() will auto-fill and auto-submit the account password for single-prompt challenges. In password-only flows this can immediately consume the keyboard-interactive attempt with the wrong secret, and users never get a chance to enter their OTP on servers that rely on keyboard-interactive only.

Useful? React with 👍 / 👎.


for (let i = 0; i < prompts.length; i++) {
const prompt = prompts[i];
if (prompt.echo) continue;
const text = (prompt.prompt || "").toLowerCase();
if (passwordPromptPattern.test(text) && !oneTimeCodePattern.test(text)) {
return i;
}
}
return -1;
}, []);

// Build auto-filled responses and check if all prompts are covered.
// Computed during render to prevent modal flash when auto-submitting.
const buildAutofilledResponses = useCallback(
(req: KeyboardInteractiveRequest): { responses: string[]; allFilled: boolean } => {
const filled = req.prompts.map(() => "");
if (req.savedPassword) {
const idx = getAutofillPasswordIndex(req.prompts);
if (idx >= 0) filled[idx] = req.savedPassword;
}
return { responses: filled, allFilled: filled.length > 0 && filled.every((r) => r.length > 0) };
},
[getAutofillPasswordIndex]
);

// Auto-submit when all prompts are filled by savedPassword (e.g. single password prompt)
const canAutoSubmit = request ? buildAutofilledResponses(request).allFilled : false;

// Reset state when request changes
useEffect(() => {
if (request) {
setResponses(request.prompts.map(() => ""));
setShowPasswords(request.prompts.map(() => false));
setIsSubmitting(false);
if (!request) return;
const { responses: filled, allFilled } = buildAutofilledResponses(request);
if (allFilled) {
onSubmit(request.requestId, filled);
return;
}
}, [request]);
setResponses(filled);
setShowPasswords(request.prompts.map(() => false));
setIsSubmitting(false);
}, [request, buildAutofilledResponses, onSubmit]);

const handleResponseChange = useCallback((index: number, value: string) => {
setResponses((prev) => {
Expand Down Expand Up @@ -93,7 +129,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
[handleSubmit, isSubmitting]
);

if (!request) return null;
if (!request || canAutoSubmit) return null;

const title = request.name?.trim() || t("keyboard.interactive.title");
const description =
Expand Down
90 changes: 51 additions & 39 deletions electron/bridges/sshAuthHelper.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,15 @@ async function findAllDefaultPrivateKeys(options = {}) {
// If only simple auth methods and no fallback keys needed, use array-based handler
if (hasExplicitAuth && !hasFallbackOptions) {
const authMethods = [];
if (effectiveAgent) authMethods.push("agent");
if (privateKey) authMethods.push("publickey");
if (password) authMethods.push("password");
authMethods.push("keyboard-interactive");
if (isPasswordOnly) {
authMethods.push("keyboard-interactive");
authMethods.push("password");
} else {
if (effectiveAgent) authMethods.push("agent");
if (privateKey) authMethods.push("publickey");
if (password) authMethods.push("password");
authMethods.push("keyboard-interactive");
}

return {
authHandler: authMethods,
Expand All @@ -205,17 +210,19 @@ async function findAllDefaultPrivateKeys(options = {}) {

// Build comprehensive authMethods array with all auth options
// Order depends on what user explicitly configured:
// - Password-only: password -> agent -> default keys -> keyboard-interactive
// - Password-only: keyboard-interactive -> password -> agent -> default keys
// - Key-only: user key -> password -> agent -> default keys -> keyboard-interactive
// - Agent configured: agent -> user key -> password -> default keys -> keyboard-interactive
// - No explicit auth: agent -> default keys -> keyboard-interactive
const authMethods = [];

if (isPasswordOnly) {
// Password-only: password first, then fallbacks
// keyboard-interactive first: many bastion/2FA setups expect password + OTP
// via interactive prompts. Starting with "password" can trigger duplicate challenges.
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
authMethods.push({ type: "password", id: "password" });
Comment on lines +222 to 223

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep password before keyboard-interactive for MFA handshakes

In buildAuthHandler's password-only dynamic branch, putting keyboard-interactive before password breaks ordered MFA flows when fallback options are present (for example, OpenSSH AuthenticationMethods password,keyboard-interactive). The auth loop marks each method as attempted before checking methodsLeft, so an initial round with only password available consumes keyboard-interactive without sending it; after password succeeds with partial auth, the next round can no longer select keyboard-interactive and authentication fails.

Useful? React with 👍 / 👎.


// Add agent and default keys AFTER password as fallback
// Add agent and default keys after password as fallback
if (sshAgentSocket) {
authMethods.push({ type: "agent", id: "agent" });
}
Expand Down Expand Up @@ -308,8 +315,10 @@ async function findAllDefaultPrivateKeys(options = {}) {
});
}

// Keyboard-interactive as last resort
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
// Keyboard-interactive as last resort (if not already present)
if (!authMethods.some((m) => m.type === "keyboard-interactive")) {
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
}

console.log(`${logPrefix} Auth methods configured`, {
isPasswordOnly,
Expand All @@ -320,47 +329,50 @@ async function findAllDefaultPrivateKeys(options = {}) {
methods: authMethods.map(m => m.id),
});

// Use dynamic authHandler to try all keys
let authIndex = 0;
const attemptedMethodIds = new Set();

const authHandler = (methodsLeft, partialSuccess, callback) => {
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];

while (authIndex < authMethods.length) {
const method = authMethods[authIndex];
authIndex++;

if (attemptedMethodIds.has(method.id)) continue;
attemptedMethodIds.add(method.id);

// Use dynamic authHandler to try all configured methods.
// Important: only mark a method as attempted when it is actually sent to ssh2.
// This avoids consuming methods that are unavailable in an earlier round but
// become available after partialSuccess (for example password -> keyboard-interactive MFA).
const attemptedMethodIds = new Set();

const authHandler = (methodsLeft, partialSuccess, callback) => {
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];

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`);
return callback("agent");
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
console.log(`${logPrefix} Trying publickey auth:`, method.id);
const pubkeyAuth = {
type: "publickey",
username,
key: method.key,
};
if (method.passphrase) {
pubkeyAuth.passphrase = method.passphrase;
}
return callback(pubkeyAuth);
attemptedMethodIds.add(method.id);
console.log(`${logPrefix} Trying publickey auth:`, method.id);
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`);
return callback({
type: "password",
username,
password,
});
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
return callback("keyboard-interactive");
}
}
return callback(false);
};
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
attemptedMethodIds.add(method.id);
return callback("keyboard-interactive");
}
}

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)
Expand Down