Files
Netcatty/components/KeychainExportPanel.tsx
T
cd7f182401 feat(ssh): host TOTP with QR scan and MFA auto-fill (#3323) (#3788)
* feat(ssh): add per-host TOTP with QR scan and MFA auto-fill

Store an optional otpauth TOTP secret on the host, encrypt and sync it
like a password, and auto-submit it once when a keyboard-interactive
prompt matches JumpServer or PAM TOTP text.

Closes #3323
Refs #3415

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): address Codex TOTP review findings

Reset authenticator editor state when the host changes, match custom
prompts with RE2, offer keyboard-interactive during connection tests
when TOTP is configured, and drop the secret when a host becomes a plugin.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): fill TOTP from the active prompt and ship re2js

Attach the connection's otpauth URI to the keyboard-interactive dialog
so Fill current code uses that attempt, including unsaved hosts. Move
re2js into production dependencies so packaged builds can match custom prompts.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): ship the shared TOTP runtime in packaged builds

electron-builder allowlists domain CommonJS modules individually, and
sshAuthHelper requires domain/totp.shared.cjs at load.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): save a pasted TOTP secret and ignore stale QR imports

Host save waits for the authenticator draft to validate before writing
the host. QR import completions from the previous host no longer change
busy state or errors on the newly selected host.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(host): ignore TOTP copy in the proxy command summary check

The assertion was matching the word secret in the authenticator UI.
It now checks that the ProxyCommand token itself is not rendered.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* feat(ssh): simplify host TOTP setup and type codes into the terminal

Move authenticator setup into a dialog so the host form keeps a single setting row, show keyboard-interactive prompts as field labels, and add a terminal context-menu action that types the current code without Enter.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep TOTP fill inside the auth dialog

Remove the terminal context-menu action. Offer the verification-code button on each non-password prompt, highlight OTP prompts, and never write the code into the password field.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep MFA instructions and scan every screen

Show keyboard-interactive name and instructions as plain text when they are not already the field label, and scan each unmatched desktop source once instead of repeating the first display.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep multiline MFA prompt directions

Show lines before the last keyboard-interactive prompt as plain-text guidance, and keep that last line as the field label.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): scan TOTP QR codes at each display's full size

Request a thumbnail as wide as the widest display and as tall as the tallest, so a portrait screen is not scaled into a landscape frame.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep TOTP fill off menu prompts

Judge the fill action from the last prompt line and the shared menu, SMS, and confirmation exclusion list. Use a secondary button on the label row, and simplify the host authenticator row.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 19:22:05 +08:00

325 lines
14 KiB
TypeScript

import React from "react";
import { ChevronRight, Info } from "lucide-react";
import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig";
import { sanitizeCredentialValue } from "../domain/credentials";
import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth";
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
import { connectionTotpFields } from "../domain/totpConfig";
import { cn } from "../lib/utils";
import { Button } from "./ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
import { toast } from "./ui/toast";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type KeychainExportPanelProps = Record<string, any>;
export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
panel,
t,
getKeyIcon,
getKeyTypeDisplay,
setShowHostSelector,
exportHost,
exportLocation,
setExportLocation,
exportFilename,
setExportFilename,
exportAdvancedOpen,
setExportAdvancedOpen,
exportScript,
setExportScript,
isExporting,
setIsExporting,
keys,
identities,
groupConfigs,
execCommand,
onSaveIdentity,
onSaveHost,
closePanel,
}) => {
return (
<>
{/* Key info card */}
<div className="flex items-center gap-3 p-3 bg-card border border-border/80 rounded-lg">
<div
className={cn(
"h-10 w-10 rounded-md flex items-center justify-center",
panel.key.certificate
? "bg-emerald-500/15 text-emerald-500"
: "bg-primary/15 text-primary",
)}
>
{getKeyIcon(panel.key)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold truncate">
{panel.key.label}
</p>
<p className="text-xs text-muted-foreground">
{t("auth.keyType", { type: getKeyTypeDisplay(panel.key) })}
</p>
</div>
</div>
{/* Export to field */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-muted-foreground">
{t("keychain.export.exportTo")}
</Label>
<Button
variant="link"
className="h-auto p-0 text-primary text-sm"
onClick={() => setShowHostSelector(true)}
>
{t("keychain.export.selectHost")}
</Button>
</div>
<Input
value={exportHost?.label || ""}
readOnly
placeholder={t("common.selectAHostPlaceholder")}
className="bg-muted/50 cursor-pointer"
onClick={() => setShowHostSelector(true)}
/>
</div>
{/* Location field */}
<div className="space-y-2">
<Label className="text-muted-foreground">
{t("keychain.export.location")}
</Label>
<Input
value={exportLocation}
onChange={(e) => setExportLocation(e.target.value)}
placeholder=".ssh"
/>
</div>
{/* Filename field */}
<div className="space-y-2">
<Label className="text-muted-foreground">
{t("keychain.export.filename")}
</Label>
<Input
value={exportFilename}
onChange={(e) => setExportFilename(e.target.value)}
placeholder="authorized_keys"
/>
</div>
{/* Info note */}
<div className="flex items-start gap-2 p-3 bg-muted/50 border border-border/60 rounded-lg">
<Info
size={14}
className="mt-0.5 text-muted-foreground shrink-0"
/>
<p className="text-xs text-muted-foreground">
{t("keychain.export.note", {
unix: "UNIX",
advanced: t("common.advanced"),
})}
</p>
</div>
{/* Advanced collapsible */}
<Collapsible
open={exportAdvancedOpen}
onOpenChange={setExportAdvancedOpen}
>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="w-full justify-between px-0 h-10 hover:bg-transparent hover:text-current"
>
<span className="font-medium">{t("common.advanced")}</span>
<ChevronRight
size={16}
className={cn(
"transition-transform",
exportAdvancedOpen && "rotate-90",
)}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
<Label className="text-muted-foreground">
{t("keychain.export.script")}
</Label>
<Textarea
value={exportScript}
onChange={(e) => setExportScript(e.target.value)}
className="min-h-[180px] font-mono text-xs"
placeholder={t("keychain.export.scriptPlaceholder")}
/>
</CollapsibleContent>
</Collapsible>
{/* Export button */}
<Button
className="w-full h-11"
disabled={
!exportHost ||
!exportLocation ||
!exportFilename ||
isExporting
}
onClick={async () => {
if (!exportHost || !panel.key.publicKey) return;
setIsExporting(true);
try {
// Apply group defaults before resolving authentication
// and connection settings for this one-off SSH command.
const effectiveExportHost = exportHost.group
? applyGroupDefaults(
exportHost,
resolveGroupDefaults(exportHost.group, groupConfigs),
)
: applyGroupDefaults(exportHost, {});
const connectionTimeouts = resolveHostSshConnectionTimeouts(effectiveExportHost);
const exportAuth = resolveHostAuth({
host: effectiveExportHost,
keys,
identities,
});
const exportKeyAuth = resolveBridgeKeyAuth({
key: exportAuth.key,
fallbackIdentityFilePaths: exportAuth.authMethod === "password" || exportAuth.keyId
? undefined
: effectiveExportHost.identityFilePaths,
passphrase: exportAuth.passphrase,
});
const exportPassword = sanitizeCredentialValue(exportAuth.password);
const exportAgentAuth = resolveBridgeSshAgentAuth(
effectiveExportHost,
exportAuth.key,
exportAuth.authMethod,
);
// Need either password or a usable key to run remote command.
if (!hasBridgeSshCredentials({
authMethod: exportAuth.authMethod,
password: exportPassword,
privateKey: exportKeyAuth.privateKey,
identityFilePaths: exportKeyAuth.identityFilePaths,
useSshAgent: exportAgentAuth.useSshAgent,
})) {
throw new Error(
t("keychain.export.missingCredentials"),
);
}
// Escape the public key for shell (single quotes, escape existing quotes)
const escapedPublicKey = panel.key.publicKey.replace(
/'/g,
"'\\''",
);
// Build the command by replacing $1, $2, $3
const scriptWithVars = exportScript
.replace(/\$1/g, exportLocation)
.replace(/\$2/g, exportFilename)
.replace(/\$3/g, `'${escapedPublicKey}'`);
// Execute the script directly - SSH exec handles multiline commands
const command = scriptWithVars;
// Execute via SSH
const result = await execCommand({
hostname: effectiveExportHost.hostname,
hostId: effectiveExportHost.id,
username: exportAuth.username || "root",
port: effectiveExportHost.port || 22,
authMethod: exportAuth.authMethod,
password: exportPassword,
privateKey: exportKeyAuth.privateKey,
certificate: exportAuth.key?.certificate,
publicKey: exportAuth.key?.publicKey,
keyId: exportAuth.keyId,
keySource: exportAuth.key?.source,
passphrase: exportKeyAuth.passphrase,
identityFilePaths: exportKeyAuth.identityFilePaths,
...exportAgentAuth,
// Carry the effective host's algorithm settings
// (host value falling back to its group default)
// so the one-off SSH exec honors them just like
// the interactive terminal does.
legacyAlgorithms: effectiveExportHost.legacyAlgorithms,
skipEcdsaHostKey: effectiveExportHost.skipEcdsaHostKey,
algorithmOverrides: effectiveExportHost.algorithms,
command,
timeout: 30000,
sshTcpConnectTimeoutMs: connectionTimeouts.tcpConnectTimeoutSeconds * 1000,
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
enableKeyboardInteractive: true,
requiresMfa: !!effectiveExportHost.requiresMfa,
...connectionTotpFields(effectiveExportHost),
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
});
// Check result - code 0, null, or undefined with no stderr is success
const exitCode = result?.code;
const hasError = result?.stderr?.trim();
if (exitCode === 0 || (exitCode == null && !hasError)) {
// Update identity (preferred) or host to use this key for authentication
if (exportHost.identityId && onSaveIdentity) {
const existing = identities.find(
(i) => i.id === exportHost.identityId,
);
if (existing) {
onSaveIdentity({
...existing,
authMethod: "key",
keyId: panel.key.id,
});
}
} else if (onSaveHost) {
onSaveHost({
...exportHost,
identityFileId: panel.key.id,
authMethod: "key",
});
}
toast.success(
t("keychain.export.successMessage", {
host: exportHost.label,
}),
t("keychain.export.successTitle"),
);
closePanel();
} else {
const errorMsg =
hasError ||
result?.stdout?.trim() ||
t("keychain.export.exitCode", { code: exitCode });
toast.error(
t("keychain.export.failedMessage", { error: errorMsg }),
t("keychain.export.failedTitle"),
);
}
} catch (err) {
const message =
err instanceof Error ? err.message : String(err);
toast.error(
t("keychain.export.failedPrefix", { error: message }),
t("keychain.export.failedTitle"),
);
} finally {
setIsExporting(false);
}
}}
>
{isExporting
? t("keychain.export.exporting")
: t("keychain.export.exportAndAttach")}
</Button>
</>
);
};