From 6892f11952bd2a79ff26cee3a1e3f31723a8d0c1 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:03:01 +0800 Subject: [PATCH 01/24] feat(ssh): add FIDO2 security key (sk-*) support with native PIN/touch GUI OpenSSH FIDO2 keys (ed25519-sk / ecdsa-sk) now authenticate through a Netcatty-owned agent and SSH_ASKPASS bridge, with Keychain generate/import UI and native PIN/touch dialogs instead of a terminal askpass. --- App.tsx | 53 ++- application/app/AppView.tsx | 12 +- application/i18n/locales/en/terminal.ts | 14 + application/i18n/locales/ru/terminal.ts | 14 + application/i18n/locales/zh-CN/terminal.ts | 14 + application/i18n/locales/zh-TW/terminal.ts | 14 + components/FidoPromptModal.test.ts | 36 ++ components/FidoPromptModal.tsx | 162 +++++++ components/KeychainManager.tsx | 40 +- components/keychain/GenerateStandardPanel.tsx | 127 ++++-- .../runtime/createTerminalSessionStarters.ts | 45 +- domain/fidoSsh.test.ts | 44 ++ domain/fidoSsh.ts | 96 ++++ domain/models/connection.ts | 3 +- domain/sshAuth.test.ts | 30 ++ domain/sshAuth.ts | 52 ++- electron/bridges/fidoAgentManager.cjs | 179 ++++++++ electron/bridges/fidoAskpass.cjs | 259 +++++++++++ electron/bridges/fidoAskpass.test.cjs | 27 ++ electron/bridges/fidoPromptHandler.cjs | 163 +++++++ electron/bridges/fidoPromptHandler.test.cjs | 63 +++ electron/bridges/fidoSshKeygen.cjs | 134 ++++++ electron/bridges/ssh2SkKeyParser.test.cjs | 63 +++ electron/bridges/sshAuthHelper.cjs | 153 ++++++- electron/bridges/sshBridge.cjs | 28 ++ electron/bridges/sshBridge/startSession.cjs | 38 +- electron/bridges/systemSshAgent.cjs | 117 ++++- electron/bridges/systemSshAgent.fido.test.cjs | 60 +++ electron/preload.cjs | 37 ++ electron/preload/api.cjs | 20 + patches/ssh2+1.17.0.patch | 419 +++++++++++++++++- types/global/netcatty-bridge-session.d.ts | 34 +- 32 files changed, 2440 insertions(+), 110 deletions(-) create mode 100644 components/FidoPromptModal.test.ts create mode 100644 components/FidoPromptModal.tsx create mode 100644 domain/fidoSsh.test.ts create mode 100644 domain/fidoSsh.ts create mode 100644 electron/bridges/fidoAgentManager.cjs create mode 100644 electron/bridges/fidoAskpass.cjs create mode 100644 electron/bridges/fidoAskpass.test.cjs create mode 100644 electron/bridges/fidoPromptHandler.cjs create mode 100644 electron/bridges/fidoPromptHandler.test.cjs create mode 100644 electron/bridges/fidoSshKeygen.cjs create mode 100644 electron/bridges/ssh2SkKeyParser.test.cjs create mode 100644 electron/bridges/systemSshAgent.fido.test.cjs diff --git a/App.tsx b/App.tsx index 8bb43e55c0..3ed136c66d 100755 --- a/App.tsx +++ b/App.tsx @@ -76,6 +76,7 @@ import { PortForwardHostKeyDialog } from './components/port-forwarding'; 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 { getHostSearchMatch } from './lib/searchMatcher'; import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells'; @@ -134,6 +135,8 @@ function App({ settings }: { settings: SettingsState }) { 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([]); @@ -792,6 +795,54 @@ function App({ settings }: { settings: SettingsState }) { // 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) => { + const bridge = netcattyBridge.get(); + void bridge?.respondFidoPrompt?.(requestId, response, false); + setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== requestId)); + }, []); + + const handleFidoPromptCancel = useCallback((requestId: string) => { + const bridge = netcattyBridge.get(); + void bridge?.respondFidoPrompt?.(requestId, '', true); + setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== 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(); @@ -1583,7 +1634,7 @@ function App({ settings }: { settings: SettingsState }) { resolveSessionAppearance={themeRuntime.resolveFocusedAppearance} t={t} /> - + ); } diff --git a/application/app/AppView.tsx b/application/app/AppView.tsx index 65eba16d10..b8363436a1 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 } from '../../components/editor/UnsavedChangesDialog'; import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider'; import { Button } from '../../components/ui/button'; @@ -105,11 +106,11 @@ export function AppView({ ctx }: { ctx: AppViewContext }) { 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, noteGroups, notes, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions, - passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, + passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resolveEmptyVaultConflict, resolvedTheme, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled, sessionRenameValue, sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap, setNavigateToSection, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride, @@ -783,6 +784,13 @@ export function AppView({ ctx }: { ctx: AppViewContext }) { 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/i18n/locales/en/terminal.ts b/application/i18n/locales/en/terminal.ts index b8b12e1c15..3fbaa36731 100644 --- a/application/i18n/locales/en/terminal.ts +++ b/application/i18n/locales/en/terminal.ts @@ -576,6 +576,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 75c2d850dd..047afab089 100644 --- a/application/i18n/locales/ru/terminal.ts +++ b/application/i18n/locales/ru/terminal.ts @@ -589,6 +589,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 708d62e5c5..81c851ee0f 100644 --- a/application/i18n/locales/zh-CN/terminal.ts +++ b/application/i18n/locales/zh-CN/terminal.ts @@ -652,6 +652,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 d1a21f8890..2ab112fc07 100644 --- a/application/i18n/locales/zh-TW/terminal.ts +++ b/application/i18n/locales/zh-TW/terminal.ts @@ -652,6 +652,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..b992307e28 --- /dev/null +++ b/components/FidoPromptModal.tsx @@ -0,0 +1,162 @@ +/** + * 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..45b2caf898 100644 --- a/components/KeychainManager.tsx +++ b/components/KeychainManager.tsx @@ -378,6 +378,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 +394,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,12 +429,17 @@ echo $3 >> "$FILE"`); return; } - // Detect key type from private key content + // Detect key type from private key content (including OpenSSH FIDO2 sk-*) 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"; + const pk = draftKey.privateKey; + const pkLower = pk.toLowerCase(); + if (pk.includes("sk-ssh-ed25519@openssh.com") || pkLower.includes("ed25519-sk")) { + detectedType = "ED25519-SK"; + } else if (pk.includes("sk-ecdsa-sha2-nistp256@openssh.com") || /ecdsa-sk|sk-ecdsa/.test(pkLower)) { + detectedType = "ECDSA-SK"; + } else if (pkLower.includes("rsa")) detectedType = "RSA"; + else if (pkLower.includes("ecdsa") || pkLower.includes("ec ")) detectedType = "ECDSA"; + else if (pkLower.includes("ed25519")) detectedType = "ED25519"; const newKey: SSHKey = { id: crypto.randomUUID(), @@ -543,7 +556,14 @@ echo $3 >> "$FILE"`); // Try to detect key type from content let detectedType: KeyType = "ED25519"; const lc = content.toLowerCase(); - if (lc.includes("rsa")) detectedType = "RSA"; + if (content.includes("sk-ssh-ed25519@openssh.com") || lc.includes("ed25519-sk")) { + detectedType = "ED25519-SK"; + } else if ( + content.includes("sk-ecdsa-sha2-nistp256@openssh.com") + || /ecdsa-sk|sk-ecdsa/.test(lc) + ) { + detectedType = "ECDSA-SK"; + } else if (lc.includes("rsa")) detectedType = "RSA"; else if (lc.includes("ecdsa") || lc.includes("ec private")) detectedType = "ECDSA"; else if (lc.includes("ed25519")) detectedType = "ED25519"; 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" + /> + +
+ + )}