diff --git a/application/app/AppHandlers.ts b/application/app/AppHandlers.ts index d35474987b..60ad21afbf 100644 --- a/application/app/AppHandlers.ts +++ b/application/app/AppHandlers.ts @@ -446,6 +446,20 @@ export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: st } } +export function handleFidoPromptSubmitImpl(getCtx: AppContextGetter, requestId: string, response: string) { + const { netcattyBridge, setFidoPromptQueue } = getCtx(); + const bridge = netcattyBridge.get(); + void bridge?.respondFidoPrompt?.(requestId, response, false); + setFidoPromptQueue((prev: { requestId: string }[]) => prev.filter((r) => r.requestId !== requestId)); +} + +export function handleFidoPromptCancelImpl(getCtx: AppContextGetter, requestId: string) { + const { netcattyBridge, setFidoPromptQueue } = getCtx(); + const bridge = netcattyBridge.get(); + void bridge?.respondFidoPrompt?.(requestId, '', true); + setFidoPromptQueue((prev: { requestId: string }[]) => prev.filter((r) => r.requestId !== requestId)); +} + export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) { const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx(); { diff --git a/application/app/AppSideEffects.tsx b/application/app/AppSideEffects.tsx index 9482a11cf3..2bef226571 100755 --- a/application/app/AppSideEffects.tsx +++ b/application/app/AppSideEffects.tsx @@ -77,13 +77,14 @@ import { toast } from '../../components/ui/toast'; import { VaultSection } from '../../components/VaultView'; import { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal'; import { PassphraseRequest } from '../../components/PassphraseModal'; +import type { FidoPromptRequest } from '../../components/FidoPromptModal'; import { classifyLocalShellType } from '../../lib/localShell'; import { useDiscoveredShells, resolveShellSetting } from '../../lib/useDiscoveredShells'; import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types'; import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider'; import { isScriptSnippet } from '../../domain/snippetScript.ts'; import { useAppStartupEffects } from './useAppStartupEffects'; -import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl } from './AppHandlers'; +import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl } from './AppHandlers'; type OpenSessionInNewWindowPayload = { title?: string; @@ -122,6 +123,8 @@ export function AppSideEffects() { const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState([]); // Passphrase request queue for encrypted SSH keys const [passphraseQueue, setPassphraseQueue] = useState([]); + // FIDO2 PIN / touch prompt queue (OpenSSH sk-*) + const [fidoPromptQueue, setFidoPromptQueue] = useState([]); const [deleteHostConfirm, setDeleteHostConfirm] = useState<{ hostId: string; name: string } | null>(null); const [pendingNewWindowSession, setPendingNewWindowSession] = useState(null); const [pendingTrayPanelConnectHostIds, setPendingTrayPanelConnectHostIds] = useState([]); @@ -793,6 +796,50 @@ export function AppSideEffects() { // Handle passphrase skip (skip this key, continue with others) const handlePassphraseSkip = useCallback((requestId: string) => { return handlePassphraseSkipImpl(() => ({ netcattyBridge, requestId, setPassphraseQueue }), requestId); }, []); + // FIDO2 PIN / touch prompts from main-process askpass / sk-helper + useEffect(() => { + const bridge = netcattyBridge.get(); + if (!bridge?.onFidoPromptRequest) return; + const unsubscribe = bridge.onFidoPromptRequest((request) => { + console.log('[App] FIDO prompt request:', request); + setFidoPromptQueue((prev) => [...prev, { + requestId: request.requestId, + kind: request.kind === 'touch' || request.kind === 'confirm' ? request.kind : 'pin', + message: request.message, + title: request.title, + keyName: request.keyName, + }]); + }); + return () => { unsubscribe?.(); }; + }, []); + + const handleFidoPromptSubmit = useCallback((requestId: string, response: string) => { + return handleFidoPromptSubmitImpl(() => ({ netcattyBridge, requestId, setFidoPromptQueue }), requestId, response); + }, []); + + const handleFidoPromptCancel = useCallback((requestId: string) => { + return handleFidoPromptCancelImpl(() => ({ netcattyBridge, requestId, setFidoPromptQueue }), requestId); + }, []); + + useEffect(() => { + const bridge = netcattyBridge.get(); + if (!bridge?.onFidoPromptTimeout) return; + const unsubscribe = bridge.onFidoPromptTimeout((event) => { + setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== event.requestId)); + toast.error(t('fido.prompt.timeout')); + }); + return () => { unsubscribe?.(); }; + }, [t]); + + useEffect(() => { + const bridge = netcattyBridge.get(); + if (!bridge?.onFidoPromptCancelled) return; + const unsubscribe = bridge.onFidoPromptCancelled((event) => { + setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== event.requestId)); + }); + return () => { unsubscribe?.(); }; + }, []); + // Handle passphrase timeout (request expired on backend) useEffect(() => { const bridge = netcattyBridge.get(); @@ -1670,6 +1717,8 @@ export function AppSideEffects() { handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, + handleFidoPromptCancel, + handleFidoPromptSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, resolveEmptyVaultConflict, @@ -1693,6 +1742,7 @@ export function AppSideEffects() { portForwardingRules, keyboardInteractiveQueue, passphraseQueue, + fidoPromptQueue, deleteHostConfirm, vaultFocusRequest, openNoteRequest, @@ -1739,6 +1789,8 @@ export function AppSideEffects() { handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, + handleFidoPromptCancel, + handleFidoPromptSubmit, handleProtocolSelect, resolveEmptyVaultConflict, handleCancelDeleteHost, @@ -1754,6 +1806,7 @@ export function AppSideEffects() { portForwardingRules, keyboardInteractiveQueue, passphraseQueue, + fidoPromptQueue, deleteHostConfirm, vaultFocusRequest, openNoteRequest, diff --git a/application/app/AppView.tsx b/application/app/AppView.tsx index 99a352b775..2778877c38 100644 --- a/application/app/AppView.tsx +++ b/application/app/AppView.tsx @@ -12,6 +12,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog'; import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal'; import { PassphraseModal } from '../../components/PassphraseModal'; +import { FidoPromptModal } from '../../components/FidoPromptModal'; import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog'; import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider'; import { Button } from '../../components/ui/button'; @@ -263,11 +264,11 @@ function AppViewInner({ domains }: AppViewProps) { followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost, handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, - handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, + handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal, hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, - passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, + passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride, @@ -931,6 +932,13 @@ function AppViewInner({ domains }: AppViewProps) { onSkip={handlePassphraseSkip} /> + {/* FIDO2 PIN / touch presence (OpenSSH sk-*) */} + + {/* Empty vault vs cloud data confirmation dialog (#679). This dialog intentionally cannot be dismissed — the user MUST choose "Restore" or "Keep Empty" before the sync flow can diff --git a/application/app/appLocalUiStore.ts b/application/app/appLocalUiStore.ts index 24c7429d1b..53a33b59f1 100644 --- a/application/app/appLocalUiStore.ts +++ b/application/app/appLocalUiStore.ts @@ -4,6 +4,7 @@ import type { Host, PortForwardingRule } from '../../domain/models'; import type { VaultSection } from '../../components/VaultView'; import type { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal'; import type { PassphraseRequest } from '../../components/PassphraseModal'; +import type { FidoPromptRequest } from '../../components/FidoPromptModal'; type Listener = () => void; @@ -31,6 +32,7 @@ export type AppLocalUiSnapshot = { portForwardingRules: readonly PortForwardingRule[]; keyboardInteractiveQueue: readonly KeyboardInteractiveRequest[]; passphraseQueue: readonly PassphraseRequest[]; + fidoPromptQueue: readonly FidoPromptRequest[]; deleteHostConfirm: { hostId: string; name: string } | null; vaultFocusRequest: unknown; openNoteRequest: unknown; @@ -49,6 +51,7 @@ export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({ portForwardingRules: Object.freeze([]) as readonly PortForwardingRule[], keyboardInteractiveQueue: Object.freeze([]) as readonly KeyboardInteractiveRequest[], passphraseQueue: Object.freeze([]) as readonly PassphraseRequest[], + fidoPromptQueue: Object.freeze([]) as readonly FidoPromptRequest[], deleteHostConfirm: null, vaultFocusRequest: null, openNoteRequest: null, @@ -81,6 +84,7 @@ class AppLocalUiStore { && prev.portForwardingRules === next.portForwardingRules && prev.keyboardInteractiveQueue === next.keyboardInteractiveQueue && prev.passphraseQueue === next.passphraseQueue + && prev.fidoPromptQueue === next.fidoPromptQueue && prev.deleteHostConfirm === next.deleteHostConfirm && prev.vaultFocusRequest === next.vaultFocusRequest && prev.openNoteRequest === next.openNoteRequest diff --git a/application/app/hosts/DialogsHost.tsx b/application/app/hosts/DialogsHost.tsx index 934e779f29..cd24fe254e 100644 --- a/application/app/hosts/DialogsHost.tsx +++ b/application/app/hosts/DialogsHost.tsx @@ -58,12 +58,15 @@ export function DialogsHost() { handlePassphraseCancel: handlers.handlePassphraseCancel, handlePassphraseSkip: handlers.handlePassphraseSkip, handlePassphraseSubmit: handlers.handlePassphraseSubmit, + handleFidoPromptCancel: handlers.handleFidoPromptCancel, + handleFidoPromptSubmit: handlers.handleFidoPromptSubmit, handleProtocolSelect: handlers.handleProtocolSelect, handleRequestCloseEditorTabRef: handlers.handleRequestCloseEditorTabRef, isCreateWorkspaceOpen: local.isCreateWorkspaceOpen, isQuickSwitcherOpen: local.isQuickSwitcherOpen, keyboardInteractiveQueue: local.keyboardInteractiveQueue, passphraseQueue: local.passphraseQueue, + fidoPromptQueue: local.fidoPromptQueue, protocolSelectHost: local.protocolSelectHost, quickResults, quickSearch: local.quickSearch, @@ -83,6 +86,7 @@ export function DialogsHost() { local.isQuickSwitcherOpen, local.keyboardInteractiveQueue, local.passphraseQueue, + local.fidoPromptQueue, local.protocolSelectHost, local.quickSearch, quickResults, diff --git a/application/i18n/locales/en/terminal.ts b/application/i18n/locales/en/terminal.ts index 99d7729eea..7a62101235 100644 --- a/application/i18n/locales/en/terminal.ts +++ b/application/i18n/locales/en/terminal.ts @@ -588,6 +588,20 @@ export const enTerminalMessages: Messages = { 'keychain.field.publicKey': 'Public key', 'keychain.field.certificatePlaceholder': 'Certificate content (optional)', 'keychain.generate.keyType': 'Key type', + 'keychain.generate.fidoHint': 'Requires a plugged-in FIDO2 security key and OpenSSH with libfido2. You may need to touch the key and enter its PIN.', + 'fido.prompt.pinTitle': 'Security key PIN', + 'fido.prompt.touchTitle': 'Touch your security key', + 'fido.prompt.pinDesc': 'Enter the PIN for {keyName}.', + 'fido.prompt.touchDesc': 'Touch or tap {keyName} to continue.', + 'fido.prompt.pinLabel': 'PIN', + 'fido.prompt.touchWaiting': 'Waiting for you to touch the security key…', + 'fido.prompt.touchDone': 'I touched it', + 'fido.prompt.timeout': 'FIDO prompt timed out. Try connecting again.', + 'fido.error.opensshMissing': 'OpenSSH with FIDO/libfido2 is required. On macOS: brew install openssh libfido2.', + 'fido.error.agentUnavailable': 'Could not start a FIDO-capable SSH agent.', + 'keychain.generate.resident': 'Resident key (store on hardware)', + 'keychain.generate.verifyRequired': 'Require PIN every use (verify-required)', + 'keychain.action.loadResident': 'Load resident keys from security key', 'keychain.generate.keySize': 'Key size', 'keychain.generate.labelPlaceholder': 'Key label', 'keychain.generate.passphrasePlaceholder': 'Passphrase (optional)', diff --git a/application/i18n/locales/ru/terminal.ts b/application/i18n/locales/ru/terminal.ts index 69123fbb4c..9275ad815c 100644 --- a/application/i18n/locales/ru/terminal.ts +++ b/application/i18n/locales/ru/terminal.ts @@ -601,6 +601,20 @@ export const ruTerminalMessages: Messages = { 'keychain.field.publicKey': 'Публичный ключ', 'keychain.field.certificatePlaceholder': 'Содержимое сертификата (необязательно)', 'keychain.generate.keyType': 'Тип ключа', + 'keychain.generate.fidoHint': 'Нужен подключённый FIDO2-ключ и OpenSSH с libfido2. Может потребоваться касание ключа и ввод PIN.', + 'fido.prompt.pinTitle': 'PIN ключа безопасности', + 'fido.prompt.touchTitle': 'Коснитесь ключа безопасности', + 'fido.prompt.pinDesc': 'Введите PIN для {keyName}.', + 'fido.prompt.touchDesc': 'Коснитесь {keyName}, чтобы продолжить.', + 'fido.prompt.pinLabel': 'PIN', + 'fido.prompt.touchWaiting': 'Ожидание касания ключа…', + 'fido.prompt.touchDone': 'Я коснулся', + 'fido.prompt.timeout': 'Время ожидания FIDO истекло. Попробуйте снова.', + 'fido.error.opensshMissing': 'Нужен OpenSSH с FIDO/libfido2. macOS: brew install openssh libfido2.', + 'fido.error.agentUnavailable': 'Не удалось запустить SSH agent с FIDO.', + 'keychain.generate.resident': 'Resident-ключ (на устройстве)', + 'keychain.generate.verifyRequired': 'Требовать PIN при каждом использовании', + 'keychain.action.loadResident': 'Загрузить resident-ключи с устройства', 'keychain.generate.keySize': 'Размер ключа', 'keychain.generate.labelPlaceholder': 'Метка ключа', 'keychain.generate.passphrasePlaceholder': 'Парольная фраза (необязательно)', diff --git a/application/i18n/locales/zh-CN/terminal.ts b/application/i18n/locales/zh-CN/terminal.ts index e6a526e56e..e1271cc3e6 100644 --- a/application/i18n/locales/zh-CN/terminal.ts +++ b/application/i18n/locales/zh-CN/terminal.ts @@ -671,6 +671,20 @@ export const zhCNTerminalMessages: Messages = { 'keychain.field.publicKey': '公钥', 'keychain.field.certificatePlaceholder': '证书内容(可选)', 'keychain.generate.keyType': '密钥类型', + 'keychain.generate.fidoHint': '需要插入 FIDO2 安全密钥,且系统 OpenSSH 需带 libfido2。生成时可能需要触摸密钥并输入 PIN。', + 'fido.prompt.pinTitle': '安全密钥 PIN', + 'fido.prompt.touchTitle': '请触摸安全密钥', + 'fido.prompt.pinDesc': '请输入 {keyName} 的 PIN。', + 'fido.prompt.touchDesc': '请触摸或轻触 {keyName} 以继续。', + 'fido.prompt.pinLabel': 'PIN', + 'fido.prompt.touchWaiting': '等待你触摸安全密钥…', + 'fido.prompt.touchDone': '已触摸', + 'fido.prompt.timeout': 'FIDO 提示已超时,请重新连接。', + 'fido.error.opensshMissing': '需要带 FIDO/libfido2 的 OpenSSH。macOS:brew install openssh libfido2。', + 'fido.error.agentUnavailable': '无法启动支持 FIDO 的 SSH agent。', + 'keychain.generate.resident': '驻留密钥(保存在硬件上)', + 'keychain.generate.verifyRequired': '每次使用需要 PIN(verify-required)', + 'keychain.action.loadResident': '从安全密钥加载驻留密钥', 'keychain.generate.keySize': '密钥长度', 'keychain.generate.labelPlaceholder': '密钥 Label', 'keychain.generate.passphrasePlaceholder': 'Passphrase(可选)', diff --git a/application/i18n/locales/zh-TW/terminal.ts b/application/i18n/locales/zh-TW/terminal.ts index 605fac8cb5..5516e7cf9d 100644 --- a/application/i18n/locales/zh-TW/terminal.ts +++ b/application/i18n/locales/zh-TW/terminal.ts @@ -671,6 +671,20 @@ export const zhTWTerminalMessages: Messages = { 'keychain.field.publicKey': '公鑰', 'keychain.field.certificatePlaceholder': '憑證內容(可選)', 'keychain.generate.keyType': '金鑰型別', + 'keychain.generate.fidoHint': '需要插入 FIDO2 安全金鑰,且系統 OpenSSH 需支援 libfido2。產生時可能需要觸碰金鑰並輸入 PIN。', + 'fido.prompt.pinTitle': '安全金鑰 PIN', + 'fido.prompt.touchTitle': '請觸摸安全金鑰', + 'fido.prompt.pinDesc': '請輸入 {keyName} 的 PIN。', + 'fido.prompt.touchDesc': '請觸摸或輕觸 {keyName} 以繼續。', + 'fido.prompt.pinLabel': 'PIN', + 'fido.prompt.touchWaiting': '等待你觸摸安全金鑰…', + 'fido.prompt.touchDone': '已觸摸', + 'fido.prompt.timeout': 'FIDO 提示已逾時,請重新連線。', + 'fido.error.opensshMissing': '需要支援 FIDO/libfido2 的 OpenSSH。macOS:brew install openssh libfido2。', + 'fido.error.agentUnavailable': '無法啟動支援 FIDO 的 SSH agent。', + 'keychain.generate.resident': '駐留金鑰(儲存在硬體上)', + 'keychain.generate.verifyRequired': '每次使用需要 PIN(verify-required)', + 'keychain.action.loadResident': '從安全金鑰載入駐留金鑰', 'keychain.generate.keySize': '金鑰長度', 'keychain.generate.labelPlaceholder': '金鑰 Label', 'keychain.generate.passphrasePlaceholder': 'Passphrase(可選)', diff --git a/components/FidoPromptModal.test.ts b/components/FidoPromptModal.test.ts new file mode 100644 index 0000000000..691acd8e64 --- /dev/null +++ b/components/FidoPromptModal.test.ts @@ -0,0 +1,36 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +test("FidoPromptModal ships PIN and touch UI wiring", () => { + const source = readFileSync(join(import.meta.dirname, "FidoPromptModal.tsx"), "utf8"); + assert.match(source, /kind === "touch"/); + assert.match(source, /fido-pin-input/); + assert.match(source, /fido\.prompt\.pinTitle/); + assert.match(source, /fido\.prompt\.touchTitle/); + assert.match(source, /onSubmit\(request\.requestId/); + assert.match(source, /onCancel\(request\.requestId/); +}); + +test("AppView mounts FidoPromptModal", () => { + const source = readFileSync( + join(import.meta.dirname, "../application/app/AppView.tsx"), + "utf8", + ); + assert.match(source, /FidoPromptModal/); + assert.match(source, /fidoPromptQueue/); + assert.match(source, /handleFidoPromptSubmit/); +}); + +test("GenerateStandardPanel exposes FIDO options", () => { + const source = readFileSync( + join(import.meta.dirname, "keychain/GenerateStandardPanel.tsx"), + "utf8", + ); + assert.match(source, /ED25519-SK/); + assert.match(source, /ECDSA-SK/); + assert.match(source, /resident/); + assert.match(source, /verifyRequired/); + assert.match(source, /fidoHint/); +}); diff --git a/components/FidoPromptModal.tsx b/components/FidoPromptModal.tsx new file mode 100644 index 0000000000..72c94210a0 --- /dev/null +++ b/components/FidoPromptModal.tsx @@ -0,0 +1,160 @@ +/** + * FIDO2 PIN / touch presence modal for OpenSSH sk-* flows. + */ +import { Fingerprint, KeyRound, Loader2, Usb } from "lucide-react"; +import React, { useCallback, useEffect, useState } from "react"; +import { useI18n } from "../application/i18n/I18nProvider"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; + +export type FidoPromptKind = "pin" | "touch" | "confirm"; + +export interface FidoPromptRequest { + requestId: string; + kind: FidoPromptKind; + message?: string; + title?: string; + keyName?: string; +} + +interface FidoPromptModalProps { + request: FidoPromptRequest | null; + onSubmit: (requestId: string, response: string) => void; + onCancel: (requestId: string) => void; +} + +export const FidoPromptModal: React.FC = ({ + request, + onSubmit, + onCancel, +}) => { + const { t } = useI18n(); + const [pin, setPin] = useState(""); + const [showPin, setShowPin] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (request) { + setPin(""); + setShowPin(false); + setIsSubmitting(false); + } + }, [request]); + + const isTouch = request?.kind === "touch" || request?.kind === "confirm"; + + const handleSubmit = useCallback(() => { + if (!request || isSubmitting) return; + if (!isTouch && !pin) return; + setIsSubmitting(true); + onSubmit(request.requestId, isTouch ? "" : pin); + }, [request, isSubmitting, isTouch, pin, onSubmit]); + + const handleCancel = useCallback(() => { + if (!request) return; + onCancel(request.requestId); + }, [request, onCancel]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !isSubmitting && (isTouch || pin)) { + e.preventDefault(); + handleSubmit(); + } + }, + [handleSubmit, isSubmitting, isTouch, pin], + ); + + if (!request) return null; + + const title = request.title + || (isTouch ? t("fido.prompt.touchTitle") : t("fido.prompt.pinTitle")); + const description = request.message?.trim() + || (isTouch + ? t("fido.prompt.touchDesc", { keyName: request.keyName || "FIDO2" }) + : t("fido.prompt.pinDesc", { keyName: request.keyName || "FIDO2" })); + + return ( + !open && handleCancel()}> + + +
+
+ {isTouch + ? + : } +
+
+ {title} + + {description} + +
+
+
+ +
+ {!isTouch && ( +
+ +
+ setPin(e.target.value)} + onKeyDown={handleKeyDown} + autoFocus + className="pr-10" + autoComplete="off" + /> + +
+
+ )} + + {isTouch && ( +
+ {t("fido.prompt.touchWaiting")} +
+ )} + +
+ + +
+
+
+
+ ); +}; + +export default FidoPromptModal; diff --git a/components/KeychainManager.tsx b/components/KeychainManager.tsx index d51cf38e87..9af7bb53c0 100644 --- a/components/KeychainManager.tsx +++ b/components/KeychainManager.tsx @@ -22,6 +22,7 @@ import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/stora import { logger } from "../lib/logger"; import { cn } from "../lib/utils"; import { Host, Identity, KeyType, ProxyProfile, SSHKey } from "../types"; +import { resolveImportedKeyType } from "./keychain/utils"; import { ManagedSource } from "../domain/models"; import { useKeychainBackend } from "../application/state/useKeychainBackend"; import SelectHostPanel from "./SelectHostPanel"; @@ -378,6 +379,12 @@ echo $3 >> "$FILE"`); type: keyType, bits: keySize, comment: `${draftKey.label.trim()}@netcatty`, + resident: keyType === "ED25519-SK" || keyType === "ECDSA-SK" + ? !!(draftKey as { resident?: boolean }).resident + : undefined, + verifyRequired: keyType === "ED25519-SK" || keyType === "ECDSA-SK" + ? !!(draftKey as { verifyRequired?: boolean }).verifyRequired + : undefined, }); if (!result) { throw new Error( @@ -388,15 +395,17 @@ echo $3 >> "$FILE"`); throw new Error(result.error || t("keychain.error.generateKeyPairFailed")); } + const resolvedType = (result.keyType as KeyType | undefined) || keyType; + const isFidoSk = resolvedType === "ED25519-SK" || resolvedType === "ECDSA-SK"; const newKey: SSHKey = { id: crypto.randomUUID(), label: draftKey.label.trim(), - type: keyType, - keySize: keyType !== "ED25519" ? keySize : undefined, + type: resolvedType, + keySize: resolvedType === "ED25519" || isFidoSk ? undefined : keySize, privateKey: result.privateKey, publicKey: result.publicKey, - passphrase: draftKey.passphrase, - savePassphrase: draftKey.savePassphrase, + passphrase: isFidoSk ? undefined : draftKey.passphrase, + savePassphrase: isFidoSk ? undefined : draftKey.savePassphrase, source: "generated", category: "key", created: Date.now(), @@ -421,17 +430,17 @@ echo $3 >> "$FILE"`); return; } - // Detect key type from private key content - let detectedType: KeyType = "ED25519"; - const pk = draftKey.privateKey.toLowerCase(); - if (pk.includes("rsa")) detectedType = "RSA"; - else if (pk.includes("ecdsa") || pk.includes("ec ")) detectedType = "ECDSA"; - else if (pk.includes("ed25519")) detectedType = "ED25519"; + // Prefer material detection over the form's seeded type (openImport always + // seeds type: "ED25519", which would discard SK detection if used first). + const detectedType = resolveImportedKeyType({ + privateKey: draftKey.privateKey, + publicKey: draftKey.publicKey, + }); const newKey: SSHKey = { id: crypto.randomUUID(), label: draftKey.label.trim(), - type: (draftKey.type as KeyType) || detectedType, + type: detectedType, privateKey: draftKey.privateKey.trim(), publicKey: draftKey.publicKey?.trim() || undefined, certificate: draftKey.certificate?.trim() || undefined, @@ -540,13 +549,10 @@ echo $3 >> "$FILE"`); reader.onload = (e) => { const content = e.target?.result as string; if (content) { - // Try to detect key type from content - let detectedType: KeyType = "ED25519"; - const lc = content.toLowerCase(); - if (lc.includes("rsa")) detectedType = "RSA"; - else if (lc.includes("ecdsa") || lc.includes("ec private")) - detectedType = "ECDSA"; - else if (lc.includes("ed25519")) detectedType = "ED25519"; + const detectedType = resolveImportedKeyType({ + privateKey: content.includes("PRIVATE KEY") ? content : content, + publicKey: content.includes("PRIVATE KEY") ? undefined : content, + }); // Extract label from filename (remove extension) const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, ""); diff --git a/components/keychain/GenerateStandardPanel.tsx b/components/keychain/GenerateStandardPanel.tsx index 7b9d925600..a03adaf6ac 100644 --- a/components/keychain/GenerateStandardPanel.tsx +++ b/components/keychain/GenerateStandardPanel.tsx @@ -12,8 +12,8 @@ import { Input } from '../ui/input'; import { Label } from '../ui/label'; interface GenerateStandardPanelProps { - draftKey: Partial; - setDraftKey: (key: Partial) => void; + draftKey: Partial & { resident?: boolean; verifyRequired?: boolean }; + setDraftKey: (key: Partial & { resident?: boolean; verifyRequired?: boolean }) => void; showPassphrase: boolean; setShowPassphrase: (show: boolean) => void; isGenerating: boolean; @@ -42,25 +42,32 @@ export const GenerateStandardPanel: React.FC = ({
-
- {(['ED25519', 'ECDSA', 'RSA'] as KeyType[]).map((t) => ( +
+ {(['ED25519', 'ECDSA', 'RSA', 'ED25519-SK', 'ECDSA-SK'] as KeyType[]).map((keyTypeOption) => ( ))}
+ {(draftKey.type === 'ED25519-SK' || draftKey.type === 'ECDSA-SK') && ( +

+ {t('keychain.generate.fidoHint')} +

+ )}
{/* Key Size selector - only for RSA and ECDSA */} @@ -88,40 +95,74 @@ export const GenerateStandardPanel: React.FC = ({
)} -
- -
- setDraftKey({ ...draftKey, passphrase: e.target.value })} - placeholder={t('keychain.generate.passphrasePlaceholder')} - className="pr-10" - /> - + {(draftKey.type === 'ED25519-SK' || draftKey.type === 'ECDSA-SK') && ( +
+
+ setDraftKey({ ...draftKey, resident: e.target.checked })} + className="h-4 w-4 rounded border-border" + /> + +
+
+ setDraftKey({ ...draftKey, verifyRequired: e.target.checked })} + className="h-4 w-4 rounded border-border" + /> + +
-
+ )} -
- setDraftKey({ ...draftKey, savePassphrase: e.target.checked })} - className="h-4 w-4 rounded border-border" - /> - -
+ {/* Soft-key file passphrase only — FIDO PIN is on the hardware token. */} + {draftKey.type !== 'ED25519-SK' && draftKey.type !== 'ECDSA-SK' && ( + <> +
+ +
+ setDraftKey({ ...draftKey, passphrase: e.target.value })} + placeholder={t('keychain.generate.passphrasePlaceholder')} + className="pr-10" + /> + +
+
+ +
+ setDraftKey({ ...draftKey, savePassphrase: e.target.checked })} + className="h-4 w-4 rounded border-border" + /> + +
+ + )}