Compare commits

...
Author SHA1 Message Date
陈奇 b6949553a5 test(ssh): configure Windows OpenSSH toolchain and document coverage 2026-10-17 13:47:37 +00:00
陈奇 cf90a88bf8 test(ssh): verify Windows fixture signatures and explicit fallback 2026-10-17 13:43:46 +00:00
陈奇 0bdfca08e6 test(ssh): exercise security key authentication on native Windows 2026-10-17 13:41:03 +00:00
陈奇 6eceffc0fb feat(ssh): guide security key recovery and explicit authentication fallback 2026-10-17 13:27:52 +00:00
netcatty-bot 4a9e266c9d fix: address Codex review on PR #2823 2026-10-17 13:13:43 +00:00
陈奇 0d1f759969 fix(ui): keep security key dialog above its remounted overlay 2026-10-17 12:44:06 +00:00
陈奇 b1b464f23c fix(ssh): align security key prompts with hardware presence and verify Docker logins 2026-10-17 12:39:31 +00:00
陈奇 a598d428f7 refactor(ui): refine security key forms and authentication motion
Use shared Select and Switch controls, concise localized copy and compact token-based dialogs. Add scoped enter/exit, field transition and presence animations with reduced-motion support. Preserve raw prompt details and visible PIN errors.
2026-10-17 12:35:43 +00:00
陈奇 4d5e16077b fix(ssh): verify and repair FIDO authentication with simulated hardware
Preserve complete OpenSSH SK signature payloads, classify combined PIN/presence prompts correctly, expand IdentityFile tokens, retain certificate selectors, and route shared-agent signing prompts to their originating window. Dismiss prompts when notification helpers exit. Add a software SK provider and opt-in real OpenSSH integration coverage for both algorithms, certificates, SFTP, forwarding, and concurrent windows.
2026-10-17 12:35:43 +00:00
陈奇 a2675f2915 feat(ssh): restore FIDO2 security key support (#2308)
Consolidate the restored feature and its review fixes before rebasing onto current main. Original PR head: 5d0a0c6c9a550364d3ec5c2331395cedfc9ea7ae.
2026-10-17 12:35:43 +00:00
70 changed files with 8342 additions and 370 deletions

No files matched your search

+41
View File
@@ -74,6 +74,47 @@ jobs:
- name: Build
run: npm run build
windows-fido:
name: windows-fido
runs-on: windows-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci --ignore-scripts
- name: Apply production dependency patches
run: npx patch-package --error-on-fail
- name: Install user-mode OpenSSH and software authenticator toolchain
uses: msys2/setup-msys2@v2
with:
msystem: MSYS
update: true
install: gcc openssl-devel openssh
- name: Build disposable software authenticator
shell: msys2 {0}
run: |
gcc -shared -DHAVE_STDINT_H scripts/fixtures/fido/provider.c -lcrypto -o /usr/lib/netcatty-fido-test-provider.dll
printf 'NETCATTY_WINDOWS_SSH_AGENT=%s\n' "$(cygpath -w /usr/bin/ssh-agent.exe)" >> "$GITHUB_ENV"
printf 'NETCATTY_WINDOWS_SSH_KEYGEN=%s\n' "$(cygpath -w /usr/bin/ssh-keygen.exe)" >> "$GITHUB_ENV"
cygpath -w /usr/bin >> "$GITHUB_PATH"
ssh -V
- name: Exercise Windows pipes, PIN bridge, SK signatures and cancellation
env:
NETCATTY_FIDO_WINDOWS: "1"
run: node --test scripts/fido-windows.live.test.cjs
windows-broadcast-ssh:
name: windows-broadcast-ssh
runs-on: windows-latest
+14
View File
@@ -500,6 +500,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();
{
+52 -1
View File
@@ -86,6 +86,7 @@ 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, ensureDiscoveredShells } from '../../lib/useDiscoveredShells';
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
@@ -94,7 +95,7 @@ import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
type OpenSessionInNewWindowPayload = {
title?: string;
@@ -139,6 +140,8 @@ export function AppSideEffects() {
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
// FIDO2 PIN / touch prompt queue (OpenSSH sk-*)
const [fidoPromptQueue, setFidoPromptQueue] = useState<FidoPromptRequest[]>([]);
const [deleteHostConfirm, setDeleteHostConfirm] = useState<{ hostId: string; name: string } | null>(null);
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
const [pendingTrayPanelConnectHostIds, setPendingTrayPanelConnectHostIds] = useState<string[]>([]);
@@ -836,6 +839,48 @@ 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, {
...request,
kind: request.kind === 'touch' || request.kind === 'confirm' || request.kind === 'recovery' ? request.kind : 'pin',
}]);
});
return () => { unsubscribe?.(); };
}, []);
const handleFidoPromptSubmit = useCallback((requestId: string, response: string) => {
if (response === 'fallback') toast.info(t('fido.recovery.passwordFallback'));
return handleFidoPromptSubmitImpl(() => ({ netcattyBridge, requestId, setFidoPromptQueue }), requestId, response);
}, [t]);
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();
@@ -1968,6 +2013,8 @@ export function AppSideEffects() {
handlePassphraseCancel,
handlePassphraseSkip,
handlePassphraseSubmit,
handleFidoPromptCancel,
handleFidoPromptSubmit,
handleProtocolSelect,
handleRequestCloseEditorTabRef,
resolveEmptyVaultConflict,
@@ -1991,6 +2038,7 @@ export function AppSideEffects() {
portForwardingRules,
keyboardInteractiveQueue,
passphraseQueue,
fidoPromptQueue,
deleteHostConfirm,
vaultFocusRequest,
openNoteRequest,
@@ -2039,6 +2087,8 @@ export function AppSideEffects() {
handlePassphraseCancel,
handlePassphraseSkip,
handlePassphraseSubmit,
handleFidoPromptCancel,
handleFidoPromptSubmit,
handleProtocolSelect,
resolveEmptyVaultConflict,
handleCancelDeleteHost,
@@ -2054,6 +2104,7 @@ export function AppSideEffects() {
portForwardingRules,
keyboardInteractiveQueue,
passphraseQueue,
fidoPromptQueue,
deleteHostConfirm,
vaultFocusRequest,
openNoteRequest,
+10 -2
View File
@@ -13,6 +13,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';
@@ -268,11 +269,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, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, 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,
@@ -1037,6 +1038,13 @@ function AppViewInner({ domains }: AppViewProps) {
onSkip={handlePassphraseSkip}
/>
{/* FIDO2 PIN / touch presence (OpenSSH sk-*) */}
<FidoPromptModal
request={fidoPromptQueue?.[0] || null}
onSubmit={handleFidoPromptSubmit}
onCancel={handleFidoPromptCancel}
/>
{/* 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
+4
View File
@@ -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
+4
View File
@@ -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,
+37
View File
@@ -631,6 +631,43 @@ export const enTerminalMessages: Messages = {
'keychain.field.publicKey': 'Public key',
'keychain.field.certificatePlaceholder': 'Certificate content (optional)',
'keychain.generate.keyType': 'Key type',
'keychain.generate.standardKeys': 'Standard keys',
'keychain.generate.securityKeys': 'Security keys · FIDO2',
'keychain.generate.residentDesc': 'Allow credentials to be recovered from this security key.',
'keychain.generate.verifyRequiredDesc': 'Ask for the device PIN before signing.',
'keychain.generate.generating': 'Generating…',
'fido.prompt.pinTouchDesc': 'Enter your PIN, then touch the key to confirm.',
'fido.prompt.details': 'Request details',
'fido.prompt.showPin': 'Show PIN',
'fido.prompt.hidePin': 'Hide PIN',
'fido.prompt.verifying': 'Verifying…',
'keychain.generate.fidoHint': 'Connect a FIDO2 security key. You may be asked for its PIN or a touch.',
'fido.prompt.pinTitle': 'Security key PIN',
'fido.prompt.touchTitle': 'Touch your security key',
'fido.prompt.pinDesc': 'Enter your security key PIN to continue.',
'fido.prompt.touchDesc': 'Touch your security key to confirm it is you.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Waiting for your key…',
'fido.prompt.touchAutomatic': 'Continues automatically after your touch',
"fido.recovery.device-unavailable.title": "Connect the matching security key",
"fido.recovery.device-unavailable.desc": "Connect the device containing this key's credential, then retry.",
"fido.recovery.timeout.title": "Security key confirmation timed out",
"fido.recovery.timeout.desc": "This device operation has stopped. Check that your key is connected, then retry and follow its touch or verification prompt.",
"fido.recovery.pin-failed.title": "Security key PIN was not accepted",
"fido.recovery.pin-failed.desc": "Check the FIDO2 PIN for this device before retrying. Repeated incorrect entries can lock the device.",
"fido.recovery.failed.title": "Security key verification did not complete",
"fido.recovery.failed.desc": "Check that the matching device is connected and supports this key. Retry, or cancel to check the device and OpenSSH configuration.",
"fido.recovery.retry": "Retry verification",
"fido.recovery.usePassword": "Use configured password",
"fido.recovery.passwordFallback": "Trying the password configured for this host",
"fido.prompt.cancelled": "Security key authentication cancelled.",
'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': 'Store credentials on the key',
'keychain.generate.verifyRequired': 'Verify PIN each time',
'keychain.action.loadResident': 'Load resident keys from security key',
'keychain.generate.keySize': 'Key size',
'keychain.generate.labelPlaceholder': 'Key label',
'keychain.generate.passphrasePlaceholder': 'Passphrase (optional)',
+37
View File
@@ -646,6 +646,43 @@ export const ruTerminalMessages: Messages = {
'keychain.field.publicKey': 'Публичный ключ',
'keychain.field.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
'keychain.generate.keyType': 'Тип ключа',
'keychain.generate.standardKeys': 'Обычные ключи',
'keychain.generate.securityKeys': 'Ключи безопасности · FIDO2',
'keychain.generate.residentDesc': 'Позволить восстановить учётные данные с ключа безопасности.',
'keychain.generate.verifyRequiredDesc': 'Запрашивать PIN устройства перед подписью.',
'keychain.generate.generating': 'Создание…',
'fido.prompt.pinTouchDesc': 'Введите PIN, затем коснитесь ключа для подтверждения.',
'fido.prompt.details': 'Подробности запроса',
'fido.prompt.showPin': 'Показать PIN',
'fido.prompt.hidePin': 'Скрыть PIN',
'fido.prompt.verifying': 'Проверка…',
'keychain.generate.fidoHint': 'Подключите ключ FIDO2. При создании может потребоваться PIN или касание.',
'fido.prompt.pinTitle': 'PIN ключа безопасности',
'fido.prompt.touchTitle': 'Коснитесь ключа безопасности',
'fido.prompt.pinDesc': 'Введите PIN ключа безопасности, чтобы продолжить.',
'fido.prompt.touchDesc': 'Коснитесь ключа безопасности, чтобы подтвердить свою личность.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Ожидание ответа ключа…',
'fido.prompt.touchAutomatic': 'Продолжим автоматически после касания',
"fido.recovery.device-unavailable.title": "Подключите нужный ключ безопасности",
"fido.recovery.device-unavailable.desc": "Подключите устройство с учётными данными этого ключа и повторите попытку.",
"fido.recovery.timeout.title": "Время подтверждения ключа истекло",
"fido.recovery.timeout.desc": "Операция устройства остановлена. Проверьте подключение ключа, повторите попытку и подтвердите её на устройстве.",
"fido.recovery.pin-failed.title": "PIN ключа безопасности не принят",
"fido.recovery.pin-failed.desc": "Проверьте PIN FIDO2 этого устройства. Повторные ошибки могут заблокировать устройство.",
"fido.recovery.failed.title": "Проверка ключа не завершена",
"fido.recovery.failed.desc": "Проверьте подключение нужного устройства и поддержку ключа. Повторите попытку или отмените её и проверьте настройки OpenSSH.",
"fido.recovery.retry": "Повторить проверку",
"fido.recovery.usePassword": "Использовать сохранённый пароль",
"fido.recovery.passwordFallback": "Вход с паролем, настроенным для этого хоста",
"fido.prompt.cancelled": "Проверка ключа безопасности отменена.",
'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': 'Хранить учётные данные на ключе',
'keychain.generate.verifyRequired': 'Проверять PIN каждый раз',
'keychain.action.loadResident': 'Загрузить resident-ключи с устройства',
'keychain.generate.keySize': 'Размер ключа',
'keychain.generate.labelPlaceholder': 'Метка ключа',
'keychain.generate.passphrasePlaceholder': 'Парольная фраза (необязательно)',
+39 -2
View File
@@ -716,13 +716,50 @@ export const zhCNTerminalMessages: Messages = {
'keychain.export.scriptRequired': '脚本 *',
'keychain.export.exportToHost': '导出到主机',
'keychain.export.failedGeneric': '导出失败:{message}',
'keychain.field.label': 'Label',
'keychain.field.labelRequired': 'Label *',
'keychain.field.label': '名称',
'keychain.field.labelRequired': '名称 *',
'keychain.field.labelPlaceholder': '密钥 Label',
'keychain.field.privateKeyRequired': '私钥 *',
'keychain.field.publicKey': '公钥',
'keychain.field.certificatePlaceholder': '证书内容(可选)',
'keychain.generate.keyType': '密钥类型',
'keychain.generate.standardKeys': '标准密钥',
'keychain.generate.securityKeys': '安全密钥 · FIDO2',
'keychain.generate.residentDesc': '允许从安全密钥中找回凭据。',
'keychain.generate.verifyRequiredDesc': '签名前验证设备 PIN,增加一层保护。',
'keychain.generate.generating': '正在生成…',
'fido.prompt.pinTouchDesc': '输入 PIN 后,触摸安全密钥确认身份。',
'fido.prompt.details': '查看请求详情',
'fido.prompt.showPin': '显示 PIN',
'fido.prompt.hidePin': '隐藏 PIN',
'fido.prompt.verifying': '正在验证…',
'keychain.generate.fidoHint': '请连接 FIDO2 安全密钥,生成时按提示输入 PIN 或触摸设备。',
'fido.prompt.pinTitle': '安全密钥 PIN',
'fido.prompt.touchTitle': '请触摸安全密钥',
'fido.prompt.pinDesc': '输入安全密钥的 PIN 以继续。',
'fido.prompt.touchDesc': '轻触安全密钥,确认是你本人在操作。',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': '等待安全密钥响应…',
'fido.prompt.touchAutomatic': '触摸后将自动继续',
"fido.recovery.device-unavailable.title": "请连接对应的安全密钥",
"fido.recovery.device-unavailable.desc": "请连接保存了此密钥凭据的设备,然后重试。",
"fido.recovery.timeout.title": "等待安全密钥确认超时",
"fido.recovery.timeout.desc": "本次设备操作已停止。请确认设备仍已连接,重试后按提示触摸或完成设备验证。",
"fido.recovery.pin-failed.title": "安全密钥 PIN 未通过验证",
"fido.recovery.pin-failed.desc": "请先检查此设备的 FIDO2 PIN,再重试。连续输错 PIN 可能导致设备锁定。",
"fido.recovery.failed.title": "安全密钥验证未完成",
"fido.recovery.failed.desc": "请确认对应设备已连接,并支持当前密钥。你可以重试,或取消后检查设备和 OpenSSH 配置。",
"fido.recovery.retry": "重试验证",
"fido.recovery.usePassword": "改用已配置的密码",
"fido.recovery.passwordFallback": "正在使用此主机已配置的密码登录",
"fido.prompt.cancelled": "已取消安全密钥验证。",
'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',
'keychain.action.loadResident': '从安全密钥加载驻留密钥',
'keychain.generate.keySize': '密钥长度',
'keychain.generate.labelPlaceholder': '密钥 Label',
'keychain.generate.passphrasePlaceholder': 'Passphrase(可选)',
+39 -2
View File
@@ -716,13 +716,50 @@ export const zhTWTerminalMessages: Messages = {
'keychain.export.scriptRequired': '指令碼 *',
'keychain.export.exportToHost': '匯出到主機',
'keychain.export.failedGeneric': '匯出失敗:{message}',
'keychain.field.label': 'Label',
'keychain.field.labelRequired': 'Label *',
'keychain.field.label': '名稱',
'keychain.field.labelRequired': '名稱 *',
'keychain.field.labelPlaceholder': '金鑰 Label',
'keychain.field.privateKeyRequired': '私鑰 *',
'keychain.field.publicKey': '公鑰',
'keychain.field.certificatePlaceholder': '憑證內容(可選)',
'keychain.generate.keyType': '金鑰型別',
'keychain.generate.standardKeys': '標準金鑰',
'keychain.generate.securityKeys': '安全金鑰 · FIDO2',
'keychain.generate.residentDesc': '允許從安全金鑰中找回憑證。',
'keychain.generate.verifyRequiredDesc': '簽章前驗證裝置 PIN,多一層保護。',
'keychain.generate.generating': '正在產生…',
'fido.prompt.pinTouchDesc': '輸入 PIN 後,觸碰安全金鑰確認身分。',
'fido.prompt.details': '檢視請求詳情',
'fido.prompt.showPin': '顯示 PIN',
'fido.prompt.hidePin': '隱藏 PIN',
'fido.prompt.verifying': '正在驗證…',
'keychain.generate.fidoHint': '請連接 FIDO2 安全金鑰,產生時依提示輸入 PIN 或觸碰裝置。',
'fido.prompt.pinTitle': '安全金鑰 PIN',
'fido.prompt.touchTitle': '請觸摸安全金鑰',
'fido.prompt.pinDesc': '輸入安全金鑰的 PIN 以繼續。',
'fido.prompt.touchDesc': '輕觸安全金鑰,確認是你本人操作。',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': '等待安全金鑰回應…',
'fido.prompt.touchAutomatic': '觸摸後將自動繼續',
"fido.recovery.device-unavailable.title": "請連接對應的安全金鑰",
"fido.recovery.device-unavailable.desc": "請連接儲存了此金鑰憑證的裝置,然後重試。",
"fido.recovery.timeout.title": "等待安全金鑰確認逾時",
"fido.recovery.timeout.desc": "本次裝置操作已停止。請確認裝置仍已連接,重試後依提示觸摸或完成裝置驗證。",
"fido.recovery.pin-failed.title": "安全金鑰 PIN 未通過驗證",
"fido.recovery.pin-failed.desc": "請先檢查此裝置的 FIDO2 PIN,再重試。連續輸錯 PIN 可能導致裝置鎖定。",
"fido.recovery.failed.title": "安全金鑰驗證未完成",
"fido.recovery.failed.desc": "請確認對應裝置已連接,並支援目前的金鑰。你可以重試,或取消後檢查裝置和 OpenSSH 設定。",
"fido.recovery.retry": "重試驗證",
"fido.recovery.usePassword": "改用已設定的密碼",
"fido.recovery.passwordFallback": "正在使用此主機已設定的密碼登入",
"fido.prompt.cancelled": "已取消安全金鑰驗證。",
'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',
'keychain.action.loadResident': '從安全金鑰載入駐留金鑰',
'keychain.generate.keySize': '金鑰長度',
'keychain.generate.labelPlaceholder': '金鑰 Label',
'keychain.generate.passphrasePlaceholder': 'Passphrase(可選)',
+36
View File
@@ -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/);
});
+155
View File
@@ -0,0 +1,155 @@
/** PIN entry and user-presence prompts from OpenSSH. */
import { Clock3, Eye, EyeOff, Hand, KeyRound, Loader2, ShieldAlert, Usb } from "lucide-react";
import React, { useCallback, useEffect, useId, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export type FidoPromptKind = "pin" | "touch" | "confirm" | "recovery";
export interface FidoPromptRequest {
requestId: string;
kind: FidoPromptKind;
message?: string;
title?: string;
keyName?: string;
notificationOnly?: boolean;
reason?: "device-unavailable" | "timeout" | "pin-failed" | "failed";
allowPasswordFallback?: boolean;
}
interface FidoPromptModalProps {
request: FidoPromptRequest | null;
onSubmit: (requestId: string, response: string) => void;
onCancel: (requestId: string) => void;
}
export const FidoPromptModal: React.FC<FidoPromptModalProps> = ({ request, onSubmit, onCancel }) => {
const { t } = useI18n();
// Retain the content during Radix's closing animation, but clear the PIN.
const [lastRequest, setLastRequest] = useState(request);
const [pin, setPin] = useState("");
const [showPin, setShowPin] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const descriptionId = useId();
useEffect(() => { if (request) setLastRequest(request); }, [request]);
useEffect(() => {
setPin("");
setShowPin(false);
setIsSubmitting(false);
}, [request?.requestId]);
const visibleRequest = request || lastRequest;
const isTouch = visibleRequest?.kind === "touch" || visibleRequest?.kind === "confirm";
const isRecovery = visibleRequest?.kind === "recovery";
const recoveryReason = visibleRequest?.reason || "failed";
const RecoveryIcon = recoveryReason === "device-unavailable" ? Usb : recoveryReason === "timeout" ? Clock3 : ShieldAlert;
const handleSubmit = useCallback(() => {
if (!request || request.notificationOnly || isSubmitting || (!isTouch && !isRecovery && !pin)) return;
setIsSubmitting(true);
onSubmit(request.requestId, isRecovery ? "retry" : isTouch ? "" : pin);
}, [request, isSubmitting, isTouch, isRecovery, pin, onSubmit]);
const handleCancel = useCallback(() => {
if (request) onCancel(request.requestId);
}, [request, onCancel]);
const message = visibleRequest?.message?.trim();
const hasPinAndTouch = !isTouch && /user presence|touch|tap/i.test(message || "");
const isErrorPrompt = /incorrect|invalid|wrong|failed|retries|attempts|错误|錯誤|剩余|剩餘/i.test(message || "");
return (
<Dialog open={!!request} onOpenChange={open => !open && handleCancel()}>
{visibleRequest && (
<DialogContent
hideCloseButton
aria-describedby={descriptionId}
overlayClassName="fido-prompt-overlay bg-black/20"
className="fido-prompt-dialog z-[51] w-[calc(100%_-_2rem)] max-w-[400px] gap-0 overflow-hidden rounded-xl border-border/70 p-6 sm:rounded-xl"
style={{ boxShadow: '0 20px 60px -20px rgb(0 0 0 / 0.28)' }}
>
<div key={visibleRequest.requestId} className="fido-prompt-stage">
{isRecovery ? (
<div className="pb-2 pt-2 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-primary/8 text-primary" aria-hidden="true">
<RecoveryIcon size={29} strokeWidth={1.5} />
</div>
<DialogTitle className="text-base">{t(`fido.recovery.${recoveryReason}.title`)}</DialogTitle>
{visibleRequest.keyName && <p className="mt-2 truncate text-sm font-medium">{visibleRequest.keyName}</p>}
<DialogDescription id={descriptionId} className="mt-2 text-sm leading-relaxed">
{t(`fido.recovery.${recoveryReason}.desc`)}
</DialogDescription>
</div>
) : isTouch ? (
<div className="pb-5 pt-2 text-center">
<div className="relative mx-auto mb-6 flex h-24 w-24 items-center justify-center" aria-hidden="true">
<span className="fido-touch-ring absolute inset-0 rounded-full bg-primary/5" />
<span className="fido-touch-ring fido-touch-ring-delayed absolute inset-0 rounded-full bg-primary/5" />
<div className="fido-touch-symbol relative flex h-16 w-16 items-center justify-center rounded-full bg-primary/8 text-primary">
<Hand size={30} strokeWidth={1.5} />
</div>
</div>
<DialogTitle className="text-base">{visibleRequest.title || t("fido.prompt.touchTitle")}</DialogTitle>
{visibleRequest.keyName && <p className="mt-2 truncate text-sm font-medium">{visibleRequest.keyName}</p>}
<DialogDescription id={descriptionId} className="mt-2 text-sm leading-relaxed">
{t("fido.prompt.touchDesc", { keyName: visibleRequest.keyName || "FIDO2" })}
</DialogDescription>
<div role="status" className="mt-5 flex items-center justify-center gap-2 text-xs text-muted-foreground">
<span className="fido-status-dot h-1.5 w-1.5 rounded-full bg-primary" aria-hidden="true" />
{t(visibleRequest.notificationOnly ? "fido.prompt.touchAutomatic" : "fido.prompt.touchWaiting")}
</div>
</div>
) : (
<>
<div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/8 text-primary" aria-hidden="true">
<KeyRound size={21} strokeWidth={1.7} />
</div>
<div className="min-w-0">
<DialogTitle className="text-base">{visibleRequest.title || t("fido.prompt.pinTitle")}</DialogTitle>
{visibleRequest.keyName && <p className="mt-1 truncate text-xs font-medium">{visibleRequest.keyName}</p>}
<DialogDescription id={descriptionId} className="mt-1 text-xs leading-relaxed">
{t(hasPinAndTouch ? "fido.prompt.pinTouchDesc" : "fido.prompt.pinDesc", { keyName: visibleRequest.keyName || "FIDO2" })}
</DialogDescription>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="fido-pin-input" className="text-xs">{t("fido.prompt.pinLabel")}</Label>
<div className="relative">
<Input id="fido-pin-input" type={showPin ? "text" : "password"}
value={pin} onChange={e => setPin(e.target.value)} disabled={isSubmitting}
onKeyDown={e => {
if (e.key === "Enter") { e.preventDefault(); handleSubmit(); }
}} autoFocus className="h-11 pr-11" autoComplete="off" spellCheck={false} />
<Button type="button" variant="ghost" size="icon" disabled={isSubmitting}
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground"
aria-label={t(showPin ? "fido.prompt.hidePin" : "fido.prompt.showPin")}
aria-pressed={showPin} onClick={() => setShowPin(value => !value)}>
{showPin ? <EyeOff size={15} /> : <Eye size={15} />}
</Button>
</div>
</div>
</>
)}
{message && (isErrorPrompt ? (
<p role="alert" className="mt-3 break-words text-xs leading-relaxed text-destructive">{message}</p>
) : (
<details className="mt-3 text-xs text-muted-foreground">
<summary className="w-fit cursor-pointer rounded-sm text-[11px] outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">{t("fido.prompt.details")}</summary>
<p className="mt-2 break-words leading-relaxed">{message}</p>
</details>
))}
</div>
<div className="-mx-6 -mb-6 mt-5 flex items-center justify-end gap-2 border-t border-border/50 bg-muted/15 px-6 py-3.5">
<Button type="button" variant="ghost" className="h-9" onClick={handleCancel} disabled={isSubmitting}>{t(visibleRequest.notificationOnly ? "common.close" : "common.cancel")}</Button>
{isRecovery && visibleRequest.allowPasswordFallback && <Button type="button" variant="outline" className="h-9" onClick={() => request && onSubmit(request.requestId, "fallback")}>{t("fido.recovery.usePassword")}</Button>}
{!visibleRequest.notificationOnly && <Button type="button" className="h-9 min-w-20 gap-2" onClick={handleSubmit} disabled={isSubmitting || (!isTouch && !isRecovery && !pin)}>
{isSubmitting && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
{t(isSubmitting ? "fido.prompt.verifying" : isRecovery ? "fido.recovery.retry" : "common.continue")}
</Button>}
</div>
</DialogContent>
)}
</Dialog>
);
};
export default FidoPromptModal;
+12 -4
View File
@@ -379,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(
@@ -389,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(),
+99 -105
View File
@@ -1,19 +1,17 @@
/**
* Generate Key Panel - Standard SSH Key generation form
*/
import { Eye, EyeOff } from 'lucide-react';
/** Standard and hardware-backed SSH key generation. */
import { Eye, EyeOff, Loader2, Usb } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { KeyType, SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '../ui/select';
import { Switch } from '../ui/switch';
interface GenerateStandardPanelProps {
draftKey: Partial<SSHKey>;
setDraftKey: (key: Partial<SSHKey>) => void;
draftKey: Partial<SSHKey> & { resident?: boolean; verifyRequired?: boolean };
setDraftKey: (key: Partial<SSHKey> & { resident?: boolean; verifyRequired?: boolean }) => void;
showPassphrase: boolean;
setShowPassphrase: (show: boolean) => void;
isGenerating: boolean;
@@ -21,119 +19,115 @@ interface GenerateStandardPanelProps {
}
export const GenerateStandardPanel: React.FC<GenerateStandardPanelProps> = ({
draftKey,
setDraftKey,
showPassphrase,
setShowPassphrase,
isGenerating,
onGenerate,
draftKey, setDraftKey, showPassphrase, setShowPassphrase, isGenerating, onGenerate,
}) => {
const { t } = useI18n();
const isFido = draftKey.type === 'ED25519-SK' || draftKey.type === 'ECDSA-SK';
const selectType = (value: string) => {
const type = value as KeyType;
setDraftKey({ ...draftKey, type, keySize: type === 'RSA' ? 4096 : type === 'ECDSA' ? 256 : undefined });
};
return (
<>
<div className="space-y-5" data-section="keychain-generate-form">
<div className="space-y-2">
<Label>{t('keychain.field.label')}</Label>
<Input
value={draftKey.label || ''}
<Label htmlFor="generate-key-label">{t('keychain.field.label')}</Label>
<Input id="generate-key-label" value={draftKey.label || ''}
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
placeholder={t('keychain.generate.labelPlaceholder')}
/>
placeholder={t('keychain.generate.labelPlaceholder')} disabled={isGenerating} />
</div>
<div className="space-y-2">
<Label>{t('keychain.generate.keyType')}</Label>
<div className="flex gap-2">
{(['ED25519', 'ECDSA', 'RSA'] as KeyType[]).map((t) => (
<Button
key={t}
variant={draftKey.type === t ? 'secondary' : 'ghost'}
className={cn(
"flex-1 h-10",
draftKey.type === t && "bg-primary/15 text-primary"
)}
onClick={() => {
// Set default keySize based on type
const defaultSize = t === 'ED25519' ? undefined : (t === 'RSA' ? 4096 : 256);
setDraftKey({ ...draftKey, type: t, keySize: defaultSize });
}}
>
{t}
</Button>
))}
</div>
<Label htmlFor="generate-key-type">{t('keychain.generate.keyType')}</Label>
<Select value={draftKey.type || 'ED25519'} onValueChange={selectType} disabled={isGenerating}>
<SelectTrigger id="generate-key-type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{t('keychain.generate.standardKeys')}</SelectLabel>
{['ED25519', 'ECDSA', 'RSA'].map(type => <SelectItem key={type} value={type}>{type}</SelectItem>)}
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>{t('keychain.generate.securityKeys')}</SelectLabel>
<SelectItem value="ED25519-SK">ED25519-SK</SelectItem>
<SelectItem value="ECDSA-SK">ECDSA-SK</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
{/* Key Size selector - only for RSA and ECDSA */}
{(draftKey.type === 'RSA' || draftKey.type === 'ECDSA') && (
<div className="space-y-2">
<Label>{t('keychain.generate.keySize')}</Label>
<div className="flex gap-2">
{(draftKey.type === 'RSA'
? [4096, 2048, 1024]
: [256, 384, 521]
).map((size) => (
<Button
key={size}
variant={draftKey.keySize === size ? 'secondary' : 'ghost'}
className={cn(
"flex-1 h-10",
draftKey.keySize === size && "bg-primary/15 text-primary"
)}
onClick={() => setDraftKey({ ...draftKey, keySize: size })}
>
{draftKey.type === 'RSA' ? `${size} bits` : `P-${size}`}
<div className="space-y-2 fido-field-stage" key="key-size">
<Label htmlFor="generate-key-size">{t('keychain.generate.keySize')}</Label>
<Select value={String(draftKey.keySize || (draftKey.type === 'RSA' ? 4096 : 256))}
onValueChange={value => setDraftKey({ ...draftKey, keySize: Number(value) })} disabled={isGenerating}>
<SelectTrigger id="generate-key-size"><SelectValue /></SelectTrigger>
<SelectContent>
{(draftKey.type === 'RSA' ? [4096, 2048, 1024] : [256, 384, 521]).map(size => (
<SelectItem key={size} value={String(size)}>{draftKey.type === 'RSA' ? `${size} bits` : `P-${size}`}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{isFido ? (
<div key="hardware" className="fido-field-stage fido-key-options space-y-4">
<div className="flex gap-2.5 rounded-lg bg-muted/40 px-3 py-3 text-xs leading-relaxed text-muted-foreground">
<Usb size={15} className="mt-0.5 shrink-0" aria-hidden="true" />
<p>{t('keychain.generate.fidoHint')}</p>
</div>
<div className="divide-y divide-border/50 border-y border-border/50">
<div className="flex items-center justify-between gap-4 py-3.5">
<div className="min-w-0 space-y-1">
<Label id="fido-resident-label" className="text-sm font-medium">{t('keychain.generate.resident')}</Label>
<p id="fido-resident-description" className="text-xs leading-relaxed text-muted-foreground">{t('keychain.generate.residentDesc')}</p>
</div>
<Switch id="fido-resident" checked={!!draftKey.resident} disabled={isGenerating}
aria-labelledby="fido-resident-label" aria-describedby="fido-resident-description"
onCheckedChange={resident => setDraftKey({ ...draftKey, resident })} />
</div>
<div className="flex items-center justify-between gap-4 py-3.5">
<div className="min-w-0 space-y-1">
<Label id="fido-verify-label" className="text-sm font-medium">{t('keychain.generate.verifyRequired')}</Label>
<p id="fido-verify-description" className="text-xs leading-relaxed text-muted-foreground">{t('keychain.generate.verifyRequiredDesc')}</p>
</div>
<Switch id="fido-verify-required" checked={!!draftKey.verifyRequired} disabled={isGenerating}
aria-labelledby="fido-verify-label" aria-describedby="fido-verify-description"
onCheckedChange={verifyRequired => setDraftKey({ ...draftKey, verifyRequired })} />
</div>
</div>
</div>
) : (
<div key="software" className="fido-field-stage space-y-4">
<div className="space-y-2">
<Label htmlFor="generate-key-passphrase">{t('terminal.auth.passphrase')}</Label>
<div className="relative">
<Input id="generate-key-passphrase" type={showPassphrase ? 'text' : 'password'}
value={draftKey.passphrase || ''} disabled={isGenerating}
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
placeholder={t('keychain.generate.passphrasePlaceholder')} className="pr-10" />
<Button type="button" variant="ghost" size="icon" disabled={isGenerating}
aria-label={t(showPassphrase ? 'hostDetails.password.hide' : 'hostDetails.password.show')}
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground"
onClick={() => setShowPassphrase(!showPassphrase)}>
{showPassphrase ? <EyeOff size={15} /> : <Eye size={15} />}
</Button>
))}
</div>
</div>
<div className="flex items-center justify-between gap-4">
<Label id="generate-save-passphrase">{t('keychain.generate.savePassphrase')}</Label>
<Switch checked={!!draftKey.savePassphrase} disabled={isGenerating}
aria-labelledby="generate-save-passphrase"
onCheckedChange={savePassphrase => setDraftKey({ ...draftKey, savePassphrase })} />
</div>
</div>
)}
<div className="space-y-2">
<Label>{t('terminal.auth.passphrase')}</Label>
<div className="relative">
<Input
type={showPassphrase ? 'text' : 'password'}
value={draftKey.passphrase || ''}
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
placeholder={t('keychain.generate.passphrasePlaceholder')}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
onClick={() => setShowPassphrase(!showPassphrase)}
>
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="savePassphrase"
checked={draftKey.savePassphrase || false}
onChange={e => setDraftKey({ ...draftKey, savePassphrase: e.target.checked })}
className="h-4 w-4 rounded border-border"
/>
<Label htmlFor="savePassphrase" className="text-sm font-normal cursor-pointer">
{t('keychain.generate.savePassphrase')}
</Label>
</div>
<Button
className="w-full h-11"
onClick={onGenerate}
disabled={isGenerating || !draftKey.label?.trim()}
>
{isGenerating ? (
<div className="h-4 w-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
) : (
t('keychain.generate.generateSave')
)}
<Button className="h-10 w-full gap-2" onClick={onGenerate} disabled={isGenerating || !draftKey.label?.trim()}>
{isGenerating && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{t(isGenerating ? 'keychain.generate.generating' : 'keychain.generate.generateSave')}
</Button>
</>
</div>
);
};
+61
View File
@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { SK_ECDSA_NISTP256, SK_SSH_ED25519 } from "../../domain/fidoSsh.ts";
import { detectKeyType, resolveImportedKeyType } from "./utils.ts";
/** Real OpenSSH shape: algorithm only after base64 decode. */
function makeSkPrivatePem(algo: string): string {
const body = Buffer.from(
`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${algo}`,
).toString("base64");
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
assert.equal(pem.includes("@openssh.com"), false);
return pem;
}
test("resolveImportedKeyType prefers FIDO detection over seeded ED25519 draft", () => {
const pem = makeSkPrivatePem(SK_SSH_ED25519);
// Simulate openImport seed + paste import (handleImport path).
const seededDraftType = "ED25519" as const;
const detected = resolveImportedKeyType({ privateKey: pem });
assert.equal(detected, "ED25519-SK");
// Product rule: material wins; never keep seeded ED25519 when SK is detected.
const savedType = detected; // same as fixed handleImport
assert.notEqual(savedType, seededDraftType);
assert.equal(savedType, "ED25519-SK");
});
test("resolveImportedKeyType distinguishes ECDSA-SK from base64-only PEM", () => {
assert.equal(
resolveImportedKeyType({ privateKey: makeSkPrivatePem(SK_ECDSA_NISTP256) }),
"ECDSA-SK",
);
});
test("detectKeyType uses the same material path for file drop", () => {
assert.equal(detectKeyType(makeSkPrivatePem(SK_SSH_ED25519)), "ED25519-SK");
assert.equal(detectKeyType("-----BEGIN RSA PRIVATE KEY-----\nMII\n-----END RSA PRIVATE KEY-----"), "RSA");
});
test("resolveImportedKeyType keeps soft keys", () => {
assert.equal(
resolveImportedKeyType({
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----",
}),
"ED25519",
);
});
test("KeychainManager handleImport uses material type not seeded draft type", () => {
const source = readFileSync(join(import.meta.dirname, "../KeychainManager.tsx"), "utf8");
// Must resolve from material; must not keep draftKey.type first (openImport seeds ED25519).
assert.match(source, /detectSshKeyType/);
assert.doesNotMatch(
source,
/type:\s*\(draftKey\.type as KeyType\)\s*\|\|\s*detectedType/,
);
assert.match(source, /type:\s*detected.type/);
});
+17 -3
View File
@@ -4,6 +4,7 @@
import { BadgeCheck, Key } from 'lucide-react';
import React from 'react';
import { detectFidoSshKeyType } from '../../domain/fidoSsh';
import { logger } from '../../lib/logger';
import { detectSshKeyType } from '../../domain/keyTypeDetect';
import { KeyType, SSHKey } from '../../types';
@@ -25,12 +26,25 @@ export const getKeyTypeDisplay = (key: SSHKey, isMac: boolean): string => {
};
/**
* Detect key type from private key content
* Resolve vault KeyType from imported key material.
*
* Always prefer material detection over the import form's seeded default
* (`openImport` seeds `type: "ED25519"`). Real OpenSSH sk private PEMs only
* expose sk-* algorithms after base64 decode — handled by detectFidoSshKeyType.
*/
export const detectKeyType = (privateKey: string): KeyType => {
return detectSshKeyType(privateKey).type;
export const resolveImportedKeyType = (args: {
privateKey: string;
publicKey?: string;
}): KeyType => {
return detectFidoSshKeyType(args) || detectSshKeyType(args.privateKey, args.publicKey).type;
};
/**
* Detect key type from private key content (file drop / paste helpers).
*/
export const detectKeyType = (privateKey: string): KeyType =>
resolveImportedKeyType({ privateKey });
/**
* Copy text to clipboard
*/
@@ -37,6 +37,7 @@ import {
sanitizeCredentialValue,
} from "../../../domain/credentials";
import { resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { requiresFidoSshAgentAuth } from "../../../domain/fidoSsh";
import {
hostRestrictsExtraSshChannels,
resolveHostKeepalive,
@@ -111,6 +112,32 @@ const createPluginConnectionRequestId = (): string => {
return `plugin-connection-${randomId}`.slice(0, 128);
};
/**
* Private key material for the bridge.
* Soft keys: PEM/OpenSSH private key for ssh2.
* FIDO2 SK: still send the OpenSSH key *handle* so the main process can
* ssh-add it into the system agent (signing stays on the hardware).
* Reference keys: path-only (no inline material).
*/
const resolveBridgePrivateKeyMaterial = (
key: SSHKey | undefined | null,
options?: { forceAgent?: boolean },
): string | undefined => {
if (!key || key.source === "reference") return undefined;
// Soft-key agent mode still omits inline privateKey when the agent will sign.
if (
options?.forceAgent
&& !requiresFidoSshAgentAuth({
type: key.type,
publicKey: key.publicKey,
privateKey: key.privateKey,
})
) {
return undefined;
}
return sanitizeCredentialValue(key.privateKey) || undefined;
};
const formatPluginDiagnosticLines = (
diagnostics: ReadonlyArray<ProviderValidationIssue> | undefined,
): string[] => (diagnostics ?? [])
@@ -451,7 +478,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
authMethod: jumpAuth.authMethod,
requiresMfa: !!jumpHost.requiresMfa,
password: jumpPassword,
privateKey: jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
}),
certificate: jumpKey?.certificate,
passphrase: jumpPassphrase,
publicKey: jumpKey?.publicKey,
@@ -679,10 +708,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
sftpReuseOptions: sftpReuseOptions?.password || sftpReuseOptions?.username !== effectiveUsername
? undefined
: sftpReuseOptions,
privateKey: attempt.key?.source === 'reference' ? undefined : (sanitizeCredentialValue(attempt.key?.privateKey) || undefined),
privateKey: resolveBridgePrivateKeyMaterial(attempt.key),
certificate: attempt.key?.certificate,
publicKey: attempt.key?.publicKey,
keyId: attempt.key?.id,
keyLabel: attempt.key?.label,
keySource: attempt.key?.source,
passphrase: attempt.key
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
@@ -855,7 +885,10 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setIsConnectionAwaitingUserInput?.(false);
ctx.setIsConnectionPastTcpDial?.(false);
const message = err instanceof Error ? err.message : String(err);
const rawMessage = err instanceof Error ? err.message : String(err);
const message = rawMessage.includes("Security key authentication cancelled")
? tr("fido.prompt.cancelled", "Security key authentication cancelled.")
: rawMessage;
const authError = isAuthError(err);
if (isJumpHostAuthError(err, message)) {
@@ -1269,7 +1302,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
authMethod,
requiresMfa: !!ctx.host.requiresMfa,
password: effectivePassword,
privateKey: (usesSystemAgent && !key?.certificate) || key?.source === 'reference' ? undefined : (sanitizeCredentialValue(key?.privateKey) || undefined),
privateKey: resolveBridgePrivateKeyMaterial(key, {
forceAgent: usesSystemAgent && !key?.certificate,
}),
certificate: key?.certificate,
keyId: key?.id,
passphrase: key && (!usesSystemAgent || Boolean(key.certificate))
@@ -1563,7 +1598,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
username: jumpAuth.username || "root",
authMethod: jumpAuth.authMethod,
password: jumpPassword,
privateKey: (jumpAgentAuth.useSshAgent && !jumpKey?.certificate) || jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
}),
certificate: jumpKey?.certificate,
passphrase: jumpAgentAuth.useSshAgent && !jumpKey?.certificate ? undefined : jumpPassphrase,
keyId: jumpAuth.keyId,
@@ -1602,7 +1639,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
hostId: ctx.host.id,
username: resolvedAuth.username || "root",
password: effectivePassword,
privateKey: (usesSystemAgent && !key?.certificate) || key?.source === 'reference' ? undefined : (sanitizeCredentialValue(key?.privateKey) || undefined),
privateKey: resolveBridgePrivateKeyMaterial(key, {
forceAgent: usesSystemAgent && !key?.certificate,
}),
certificate: key?.certificate,
keyId: key?.id,
passphrase: key && (!usesSystemAgent || Boolean(key.certificate))
+156
View File
@@ -0,0 +1,156 @@
# FIDO2 SSH validation without hardware
Run the real OpenSSH integration simulation on Linux:
```sh
npm run test:fido-sim
```
Prerequisites: Node.js 22+, a C compiler, OpenSSL development headers, and
OpenSSH `ssh-keygen`, `/usr/bin/ssh-agent`, `ssh-add`, `ssh-sk-helper`, and
`/usr/sbin/sshd`. The test runs an unprivileged server bound to a random
localhost port and uses Netcatty's managed temporary directory. It does not
modify system SSH configuration or the user's login agent.
The opt-in test compiles `scripts/fixtures/fido/provider.c` as an OpenSSH SK
provider and exercises the production key generation, askpass and agent helpers.
It covers both ED25519-SK and ECDSA-SK, PIN-required enrollment and signatures,
tokenized IdentityFile references, inline handles, SSH command execution, SFTP,
TCP forwarding, CA certificates under IdentitiesOnly, and simultaneous callers
from separate windows. The independent OpenSSH server verifies the signatures.
The provider is strictly a test fixture: its opaque handles contain disposable
software private keys, and its PIN is always `123456`. It must never be selected
for a real account. Its resident flag support only simulates enrollment; it does
not enumerate stored resident credentials.
The ordinary test suite also exercises PIN/touch helper subprocesses, modal
cancellation, helper-exit cleanup, platform selection, resource release and SK
signature framing. `npm test` skips the opt-in integration simulation.
This verifies the OpenSSH integration on Linux with a software authenticator.
Real USB/CTAP devices and Windows/macOS installations still need device testing.
## Native Windows integration check
The `windows-fido` job in `.github/workflows/test.yml` runs native Windows Node
with real MSYS2 OpenSSH and an MSYS-built version of the disposable provider.
It exercises Windows named-pipe askpass IPC, enrollment and actual signatures
for both SK algorithms, PIN plus a separately gated touch, incorrect PIN,
missing-device cancellation with a saved password, and explicit password
fallback. A local SSH protocol server verifies the signatures and executes a
test command. The independent Docker/OpenSSH server check above runs on Linux.
This checks a specific **user-mode MSYS2 OpenSSH** configuration. It does not
establish support for every Windows OpenSSH distribution, the Windows system
agent service, Git for Windows, native WebAuthn dialogs, or physical USB devices.
Windows currently has no `ssh-sk-helper` error observer: recovery must retain
the generic verification-failed reason rather than guessing which device error
occurred. The Unix helper's 30-second cancellation deadline also does not apply
to this Windows path.
For a physical-device check of this configuration, launch Netcatty with
`NETCATTY_SSH_AGENT_PATH` and `NETCATTY_SSH_KEYGEN_PATH` pointing to the matching
MSYS2 executables and put that installation's `usr/bin` on `PATH`. Use its real
built-in FIDO provider; **never** set `SSH_SK_PROVIDER` to the CI fixture. Check
the complete application flow against a separate SSH server: select the saved
SK identity on a host, connect, fulfill the actual PIN/touch requests, execute a
command, and repeat with the device absent, removed, and with PIN cancellation.
Record the Windows/OpenSSH/device versions and which dialogs are owned by the
application or OS. A successful software-provider CI run does not replace this
hardware check.
## Docker server and gated user presence
```sh
npm run test:fido-docker
```
Requires Linux, Docker, a C compiler, OpenSSL headers and the OpenSSH client
tools above. Host `sshd` is not required. The command builds a disposable Debian
OpenSSH server, publishes a random port on `127.0.0.1`, and removes its container
and temporary files on completion. The server accepts only public-key login as
the test user `netcatty`. Host keys are generated when the container starts.
Only disposable public authorized keys are mounted into the server; the client
and software provider run on the host through Netcatty's production helpers.
For each of ED25519-SK and ECDSA-SK, both touch-only and PIN-plus-touch modes
are exercised. The fixture's optional `NETCATTY_FIDO_SIM_CONTROL` file gates the
actual signature: the test first asserts that login has not completed, then
simulates physical touch independently of the renderer. No renderer response
is sent for OpenSSH's touch notifications. The test verifies automatic prompt
dismissal, command execution and SFTP, as well as rejection of missing touch,
device removal, wrong/cancelled PIN, and signed responses missing the UP or UV
flag. The latter assertions also check the independent server's rejection log.
A fresh login after each group of failures checks that the agent remains usable.
The fixture accelerates the simulated touch timeout to three seconds; it does
not reproduce device retry counters, PIN lockout or CTAP/USB transport.
## Expected security-key flow
This feature implements **FIDO2 OpenSSH security keys**, not the YubiKey PIV or
OpenPGP applications. The public key must first be installed on the SSH server.
During login, OpenSSH asks the device to sign; normal keys require touch, while
`verify-required` additionally requests user verification (typically the device
PIN). The private key remains on the real hardware; Netcatty stores its handle.
Resident credentials support recovery of handles on another machine, and do not
remove the presence/verification requirement.
OpenSSH's `notify_start()` invokes askpass with `SSH_ASKPASS_PROMPT=none` and
terminates the notification helper when the hardware operation finishes. That
mode is forwarded to the UI as `notificationOnly`: there is no Continue button,
and the dialog closes automatically when the helper exits. Closing this hint
only dismisses the notification; it does not authorize or cancel the hardware
operation. PIN prompts still have explicit Continue and Cancel actions. OpenSSH
can combine PIN and touch instructions into one prompt, so the UI also explains
that the user should touch the device after entering the PIN. The exact prompt
order depends on the OpenSSH/device implementation; the UI follows its requests.
For the agent's combined PIN/presence prompt, the UI keeps a touch-only waiting
hint after PIN submission, tied to the real signing operation's completion.
The feature is vendor-neutral: compatible security keys from other vendors can
be used. Check the device's algorithm support: ECDSA-SK and ED25519-SK are not
universally interchangeable. PIN/resident/biometric support also varies. A
touch indicates physical presence; it does not imply fingerprint recognition.
### Recovery and alternative authentication
On supported Unix OpenSSH installations, Netcatty wraps `ssh-sk-helper` to
observe its framed error reply before `ssh-agent` collapses it into generic
agent failure. The wrapper forwards the request/reply bytes unchanged and never
logs request contents, PINs or signatures. It pairs with the selected OpenSSH
helper, honors an existing `SSH_SK_HELPER`, and lives in the managed temp dir.
Unrecognized helper reply versions retain generic recovery while their bytes
are forwarded unchanged.
If no compatible wrapper is available (including native Windows agents), use
the generic verification-failed message instead of inventing a missing-device
diagnosis. Real Windows/macOS device behavior still requires hardware testing.
| Situation | Behavior |
| --- | --- |
| Matching device unavailable | Show the selected key's label and ask to connect its device; Retry continues the pending login. |
| Waiting for device confirmation | Show a touch/verification hint; the device completes it automatically. Closing the hint only hides it. |
| Device operation exceeds 30 seconds | Stop that helper operation, discard late signatures and offer Retry. PIN entry time is excluded. |
| Device removed while waiting | The helper's device-unavailable result returns to the connection/retry prompt. |
| PIN rejected | Explain that the device PIN was not accepted; require an explicit retry, without guessing remaining attempts. |
| Other helper failure | Offer generic recovery; do not claim the device is missing. |
| Saved password available | Offer an explicit "Use configured password" action after a failed device operation. Cancel does not select the password. |
| Device removed after SSH login | The established SSH session continues; the device signs authentication, not each terminal command. |
The configured SSH authentication deadline still bounds the pending connection.
Device recovery also closes when its owning session/window is torn down. A
lost prompt window or explicit cancellation fails closed; late results cannot
authorize the cancelled attempt.
The Docker suite accelerates the helper deadline to 1.5 seconds and verifies
both key algorithms, device insertion/retry, removal, timeout, incorrect PIN,
unknown/unsupported failures, explicit password fallback and cancellation with
a valid saved password. UI recordings shorten the deadline to 4.5 seconds and
use the complete host -> key selection -> production SSH bridge -> Docker shell
path; only the Electron transport and physical authenticator are simulated.
References:
- [Yubico: Securing SSH with FIDO2](https://developers.yubico.com/SSH/Securing_SSH_with_FIDO2.html)
- [OpenSSH notify_start/notify_complete](https://github.com/openssh/openssh-portable/blob/V_10_0_P2/readpass.c)
- [OpenSSH agent security-key signing](https://github.com/openssh/openssh-portable/blob/V_10_0_P2/ssh-agent.c)
+88
View File
@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
SK_ECDSA_NISTP256,
SK_SSH_ED25519,
decodeOpenSshPrivateKeyBody,
detectFidoSshKeyType,
extractOpenSshPublicKeyType,
isSkPrivateKey,
isSkPublicKey,
requiresFidoSshAgentAuth,
} from "./fidoSsh.ts";
const skEdPub =
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= user@fido";
const skEcdsaPub =
"sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= user@fido";
test("extractOpenSshPublicKeyType recognizes sk algorithms", () => {
assert.equal(extractOpenSshPublicKeyType(skEdPub), SK_SSH_ED25519);
assert.equal(extractOpenSshPublicKeyType(skEcdsaPub), SK_ECDSA_NISTP256);
assert.equal(extractOpenSshPublicKeyType("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJust test"), "ssh-ed25519");
assert.equal(isSkPublicKey(skEdPub), true);
assert.equal(isSkPublicKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJust test"), false);
});
test("extractOpenSshPublicKeyType recognizes OpenSSH sk certificate algorithms", () => {
const skEdCert =
"sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA user@fido";
const skEcdsaCert =
"sk-ecdsa-sha2-nistp256-cert-v01@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTYtY2VydC12MDFAb3BlbnNzaC5jb20AAAA user@fido";
assert.equal(extractOpenSshPublicKeyType(skEdCert), SK_SSH_ED25519);
assert.equal(extractOpenSshPublicKeyType(skEcdsaCert), SK_ECDSA_NISTP256);
assert.equal(isSkPublicKey(skEdCert), true);
assert.equal(detectFidoSshKeyType({ publicKey: skEdCert }), "ED25519-SK");
assert.equal(requiresFidoSshAgentAuth({ publicKey: skEcdsaCert }), true);
});
test("detectFidoSshKeyType maps vault and OpenSSH types", () => {
assert.equal(detectFidoSshKeyType({ type: "ED25519-SK" }), "ED25519-SK");
assert.equal(detectFidoSshKeyType({ publicKey: skEdPub }), "ED25519-SK");
assert.equal(detectFidoSshKeyType({ publicKey: skEcdsaPub }), "ECDSA-SK");
assert.equal(detectFidoSshKeyType({ type: "ED25519", publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJust test" }), undefined);
});
/** Real-shaped PEM: sk type only appears after base64 decode (`@` not in b64 alphabet). */
function makeSkPrivatePem(algo: string): string {
const raw = Buffer.from(`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${algo}`);
const body = raw.toString("base64");
// Guard: the PEM text must NOT contain the plain algorithm (skeptic regression).
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
assert.equal(pem.includes("@openssh.com"), false);
return pem;
}
test("isSkPrivateKey detects OpenSSH sk key handles", () => {
const pem = makeSkPrivatePem(SK_SSH_ED25519);
assert.equal(isSkPrivateKey(pem), true);
assert.equal(isSkPrivateKey("-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----"), false);
assert.equal(requiresFidoSshAgentAuth({ publicKey: skEdPub }), true);
assert.equal(requiresFidoSshAgentAuth({ privateKey: pem }), true);
assert.equal(requiresFidoSshAgentAuth({ type: "ED25519", privateKey: "soft-key" }), false);
});
test("detectFidoSshKeyType reads sk algorithm from base64-only private PEM", () => {
const edPem = makeSkPrivatePem(SK_SSH_ED25519);
const ecPem = makeSkPrivatePem(SK_ECDSA_NISTP256);
assert.equal(detectFidoSshKeyType({ privateKey: edPem }), "ED25519-SK");
assert.equal(detectFidoSshKeyType({ privateKey: ecPem }), "ECDSA-SK");
});
test("decodeOpenSshPrivateKeyBody works without Node Buffer (renderer)", () => {
const pem = makeSkPrivatePem(SK_SSH_ED25519);
const originalBuffer = globalThis.Buffer;
// @ts-expect-error intentional renderer simulation
delete globalThis.Buffer;
try {
assert.equal(typeof globalThis.Buffer, "undefined");
const decoded = decodeOpenSshPrivateKeyBody(pem);
assert.ok(decoded);
assert.equal(decoded.includes(SK_SSH_ED25519), true);
assert.equal(detectFidoSshKeyType({ privateKey: pem }), "ED25519-SK");
} finally {
globalThis.Buffer = originalBuffer;
}
});
+137
View File
@@ -0,0 +1,137 @@
/**
* OpenSSH FIDO2 / U2F security-key (sk-*) helpers.
*
* Netcatty does not drive CTAP itself. SK keys authenticate through the system
* OpenSSH agent + ssh-sk-helper (libfido2). These helpers detect sk material so
* the app can force the agent path instead of software privateKey signing.
*/
export const SK_SSH_ED25519 = "sk-ssh-ed25519@openssh.com";
export const SK_ECDSA_NISTP256 = "sk-ecdsa-sha2-nistp256@openssh.com";
export type SkPublicKeyType = typeof SK_SSH_ED25519 | typeof SK_ECDSA_NISTP256;
export type FidoSshKeyType = "ED25519-SK" | "ECDSA-SK";
const SK_PUBLIC_KEY_TYPES = new Set<string>([SK_SSH_ED25519, SK_ECDSA_NISTP256]);
const OPENSSH_PRIVATE_KEY_RE =
/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END OPENSSH PRIVATE KEY-----/;
export const isSkPublicKeyType = (type: string | undefined | null): type is SkPublicKeyType =>
typeof type === "string" && SK_PUBLIC_KEY_TYPES.has(type.trim());
export const isFidoSshVaultKeyType = (
type: string | undefined | null,
): type is FidoSshKeyType => type === "ED25519-SK" || type === "ECDSA-SK";
/** Parse the algorithm field from an OpenSSH authorized_keys / .pub line. */
export const extractOpenSshPublicKeyType = (
publicKey: string | undefined | null,
): string | undefined => {
if (typeof publicKey !== "string") return undefined;
const line = publicKey
.split(/\r?\n/)
.map((entry) => entry.trim())
.find((entry) => entry && !entry.startsWith("#"));
if (!line) return undefined;
// OpenSSH certificate algorithms embed `-cert-v01` *before* `@openssh.com`
// (e.g. `sk-ssh-ed25519-cert-v01@openssh.com`), not after the RP id.
const skCert = /^(sk-(?:ssh-ed25519|ecdsa-sha2-nistp256))-cert-v0[01]@openssh\.com\s+/.exec(line);
if (skCert) return `${skCert[1]}@openssh.com`;
const match = /^(sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com|ssh-(?:rsa|ed25519|dss)|ecdsa-sha2-nistp(?:256|384|521))(?:-cert-v0[01]@openssh\.com)?\s+/.exec(line);
return match?.[1];
};
export const isSkPublicKey = (publicKey: string | undefined | null): boolean =>
isSkPublicKeyType(extractOpenSshPublicKeyType(publicKey));
/**
* Renderer-safe base64 decode. Avoids Node `Buffer`, which is unavailable in
* Electron windows with `nodeIntegration: false`.
*/
const decodeBase64ToBinaryString = (payload: string): string | null => {
const normalized = payload.replace(/\s+/g, "");
if (!normalized) return null;
try {
if (typeof atob === "function") {
return atob(normalized);
}
} catch {
// fall through to Node Buffer when present (tests / main)
}
if (typeof Buffer !== "undefined") {
try {
return Buffer.from(normalized, "base64").toString("binary");
} catch {
return null;
}
}
return null;
};
/**
* Decode OpenSSH private-key PEM body (base64) for algorithm probing.
* Real sk-* handles only contain `sk-*-@openssh.com` *inside* the base64 payload
* (`@` is not a base64 alphabet character), so raw-text regex on PEM is insufficient.
*/
export const decodeOpenSshPrivateKeyBody = (
privateKey: string | undefined | null,
): string | null => {
if (typeof privateKey !== "string" || !privateKey.trim()) return null;
const match = OPENSSH_PRIVATE_KEY_RE.exec(privateKey);
if (!match) return null;
return decodeBase64ToBinaryString(match[1]);
};
const skTypeInText = (text: string | undefined | null): FidoSshKeyType | undefined => {
if (!text) return undefined;
if (text.includes(SK_ECDSA_NISTP256) || /ecdsa-sk|sk-ecdsa/i.test(text)) return "ECDSA-SK";
if (text.includes(SK_SSH_ED25519) || /ed25519-sk|sk-ssh-ed25519/i.test(text)) return "ED25519-SK";
return undefined;
};
/**
* Best-effort detection of an OpenSSH sk-* private key (handle) blob.
* Decodes the base64 body and looks for the algorithm string.
*/
export const isSkPrivateKey = (privateKey: string | undefined | null): boolean => {
if (typeof privateKey !== "string" || !privateKey.trim()) return false;
const decoded = decodeOpenSshPrivateKeyBody(privateKey);
if (decoded) {
return decoded.includes(SK_SSH_ED25519) || decoded.includes(SK_ECDSA_NISTP256);
}
// Some exporters only put the type on a comment line / nearby text.
return /sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com/.test(privateKey);
};
export const detectFidoSshKeyType = (args: {
type?: string | null;
publicKey?: string | null;
privateKey?: string | null;
}): FidoSshKeyType | undefined => {
if (isFidoSshVaultKeyType(args.type)) return args.type;
const pubType = extractOpenSshPublicKeyType(args.publicKey);
if (pubType === SK_SSH_ED25519) return "ED25519-SK";
if (pubType === SK_ECDSA_NISTP256) return "ECDSA-SK";
if (isSkPrivateKey(args.privateKey)) {
const decoded = decodeOpenSshPrivateKeyBody(args.privateKey);
return skTypeInText(decoded) || skTypeInText(args.privateKey) || "ED25519-SK";
}
return undefined;
};
/** True when vault/host material must authenticate via agent + hardware helper. */
export const requiresFidoSshAgentAuth = (args: {
type?: string | null;
publicKey?: string | null;
privateKey?: string | null;
}): boolean => Boolean(detectFidoSshKeyType(args) || isSkPublicKey(args.publicKey) || isSkPrivateKey(args.privateKey));
export const fidoSshKeyTypeToOpenSsh = (type: FidoSshKeyType): SkPublicKeyType =>
type === "ECDSA-SK" ? SK_ECDSA_NISTP256 : SK_SSH_ED25519;
export const openSshSkTypeToVaultKeyType = (type: string | undefined | null): FidoSshKeyType | undefined => {
if (type === SK_SSH_ED25519) return "ED25519-SK";
if (type === SK_ECDSA_NISTP256) return "ECDSA-SK";
return undefined;
};
+3
View File
@@ -9,6 +9,7 @@
*/
import type { KeyType } from './models';
import { detectFidoSshKeyType } from './fidoSsh';
export interface SshKeyTypeInfo {
type: KeyType;
@@ -154,6 +155,8 @@ export const detectSshKeyType = (
privateKey?: string | null,
publicKey?: string | null,
): SshKeyTypeInfo => {
const fidoType = detectFidoSshKeyType({ privateKey, publicKey });
if (fidoType) return { type: fidoType };
const fromPrivate = privateKey ? detectFromPrivateKey(privateKey) : undefined;
if (fromPrivate) return fromPrivate;
const fromPublic = publicKey ? detectFromPublicKey(publicKey) : undefined;
+2 -1
View File
@@ -313,7 +313,8 @@ export interface Host {
order?: number;
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
/** Software keys plus OpenSSH FIDO2 security-key types (agent + hardware only). */
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519' | 'ED25519-SK' | 'ECDSA-SK';
type KeySource = 'generated' | 'imported' | 'reference';
export type KeyCategory = 'key' | 'certificate' | 'identity';
type IdentityAuthMethod = 'password' | 'key' | 'certificate';
+109
View File
@@ -99,6 +99,36 @@ test("resolveBridgeSshAgentAuth carries system agent settings without private ma
);
});
test("resolveBridgeSshAgentAuth forces agent auth for FIDO2 SK vault keys", () => {
const skKey: SSHKey = {
id: "sk-1",
label: "YubiKey",
type: "ED25519-SK",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
publicKey: "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= yubi",
source: "imported",
category: "key",
created: 1,
};
assert.deepEqual(
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skKey, "key"),
{
useSshAgent: true,
identityAgent: undefined,
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: undefined,
agentPublicKeys: [skKey.publicKey!],
},
);
assert.equal(
resolveBridgeKeyAuth({ key: skKey }).privateKey?.includes("OPENSSH PRIVATE KEY"),
true,
);
});
test("resolveBridgeSshAgentAuth keeps certificate authentication independent", () => {
assert.deepEqual(
resolveBridgeSshAgentAuth({
@@ -109,6 +139,85 @@ test("resolveBridgeSshAgentAuth keeps certificate authentication independent", (
);
});
test("resolveBridgeSshAgentAuth keeps agent path for FIDO SK certificates", () => {
const skCertKey: SSHKey = {
id: "sk-cert-1",
label: "YubiKey cert",
type: "ED25519-SK",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
publicKey: "sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA yubi",
certificate: "sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA yubi",
source: "imported",
category: "certificate",
created: 1,
};
assert.deepEqual(
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skCertKey, "certificate"),
{
useSshAgent: true,
identityAgent: undefined,
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: undefined,
agentPublicKeys: [skCertKey.publicKey!],
},
);
});
test("resolveBridgeSshAgentAuth selects staged SK certificate under IdentitiesOnly", () => {
const barePub =
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= yubi";
const cert =
"sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA yubi";
const skCertKey: SSHKey = {
id: "sk-cert-bare-1",
label: "YubiKey cert staged",
type: "ED25519-SK",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
publicKey: barePub,
certificate: cert,
source: "imported",
category: "certificate",
created: 1,
};
assert.deepEqual(
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skCertKey, "certificate"),
{
useSshAgent: true,
identityAgent: undefined,
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: undefined,
agentPublicKeys: [barePub, cert],
},
);
});
test("resolveBridgeSshAgentAuth forces agent for path-only SK vault type", () => {
const skRef: SSHKey = {
id: "sk-ref-1",
label: "YubiKey file",
type: "ED25519-SK",
source: "reference",
filePath: "/home/user/.ssh/id_ed25519_sk",
category: "key",
created: 1,
};
assert.deepEqual(
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skRef, "key"),
{
useSshAgent: true,
identityAgent: undefined,
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: undefined,
},
);
});
test("resolveBridgeSshAgentAuth forwards a selected vault public key", () => {
assert.deepEqual(
resolveBridgeSshAgentAuth({
+91 -10
View File
@@ -2,6 +2,7 @@ import type { GroupConfig, Host, HostAuthMethod, Identity, SSHKey } from "./mode
import { sanitizeCredentialValue } from "./credentials";
import { applyGroupDefaults } from "./groupConfig";
import { isSshAgentNoneValue } from "./sshAgentSettings";
import { requiresFidoSshAgentAuth } from "./fidoSsh";
type HostAuthOverride = {
authMethod?: HostAuthMethod;
@@ -240,10 +241,30 @@ export const resolveBridgeKeyAuth = (args: {
passphrase?: string;
} => {
const { key, fallbackIdentityFilePaths, passphrase } = args;
const isFidoSk = requiresFidoSshAgentAuth({
type: key?.type,
publicKey: key?.publicKey,
privateKey: key?.privateKey,
});
const identityFilePaths = key?.source === "reference" && key.filePath
? [key.filePath]
: fallbackIdentityFilePaths;
// SK private material is an OpenSSH key *handle*. Pass it through so the
// main process can load it into the system agent; never use it for local
// software signing (startSession routes SK handles to ssh-add).
if (isFidoSk) {
return {
privateKey: key?.source === "reference"
? undefined
: sanitizeCredentialValue(key?.privateKey),
identityFilePaths: key?.source === "reference" && key.filePath
? [key.filePath]
: identityFilePaths,
passphrase: sanitizeCredentialValue(passphrase ?? key?.passphrase),
};
}
return {
privateKey: key?.source === "reference" ? undefined : sanitizeCredentialValue(key?.privateKey),
identityFilePaths,
@@ -251,9 +272,23 @@ export const resolveBridgeKeyAuth = (args: {
};
};
/** Preferred agent identity blobs for IdentitiesOnly selection. */
const resolveAgentPublicKeys = (
key?: Pick<SSHKey, "publicKey" | "certificate">,
): string[] | undefined => {
const keys: string[] = [];
const publicKey = key?.publicKey?.trim();
const certificate = key?.certificate?.trim();
if (publicKey) keys.push(publicKey);
// ssh-add -L lists the bare key and certificate as distinct identities; when
// the vault stores them separately, both must be selectable under IdentitiesOnly.
if (certificate && certificate !== publicKey) keys.push(certificate);
return keys.length > 0 ? keys : undefined;
};
export const resolveBridgeSshAgentAuth = (
host: Pick<Host, "authMethod" | "useSshAgent" | "identityAgent" | "identityFilePaths" | "identitiesOnly" | "addKeysToAgent" | "useKeychain" | "agentForwarding">,
key?: Pick<SSHKey, "certificate" | "publicKey" | "source" | "filePath">,
key?: Pick<SSHKey, "certificate" | "publicKey" | "source" | "filePath" | "type" | "privateKey">,
authMethod?: HostAuthMethod,
): {
useSshAgent?: boolean;
@@ -268,25 +303,70 @@ export const resolveBridgeSshAgentAuth = (
&& !isSshAgentNoneValue(host.identityAgent)
? { identityAgent: host.identityAgent }
: {};
if (authMethod === "password" || authMethod === "certificate" || key?.certificate?.trim()) {
const isFidoSk = requiresFidoSshAgentAuth({
type: key?.type,
publicKey: key?.publicKey,
privateKey: key?.privateKey,
});
const hasAgentSelector = Boolean(
key?.publicKey?.trim()
|| (key?.source === "reference" && key.filePath?.trim())
|| host.identityFilePaths?.some((filePath) => filePath.trim())
// Inline imported sk private key handle still needs agent; selection uses
// the public key when present, otherwise the agent is used non-strictly.
|| (isFidoSk && key?.privateKey?.trim()),
);
// FIDO2 SK keys always require the system agent (hardware signs via
// ssh-sk-helper), whether selected explicitly or via a CA certificate
// wrapping the SK public key.
const fidoAgentAuth = () => {
if (!hasAgentSelector && !key?.privateKey?.trim()) {
return { useSshAgent: false };
}
const agentPublicKeys = resolveAgentPublicKeys(key);
return {
useSshAgent: true,
identityAgent: host.identityAgent,
identitiesOnly: Boolean(key?.publicKey?.trim() || (key?.source === "reference" && key.filePath)),
addKeysToAgent: host.addKeysToAgent ?? "yes",
useKeychain: host.useKeychain,
...(agentPublicKeys ? { agentPublicKeys } : {}),
};
};
if (authMethod === "password") {
return { useSshAgent: false, ...forwardingAgent };
}
if (authMethod === "key") {
const hasAgentSelector = Boolean(
key?.publicKey?.trim()
|| (key?.source === "reference" && key.filePath?.trim())
|| host.identityFilePaths?.some((filePath) => filePath.trim()),
);
if (authMethod === "certificate" || key?.certificate?.trim()) {
// A certificate over an SK public key still needs the agent for hardware
// signing; only non-FIDO certificate auth bypasses the agent entirely.
if (isFidoSk) {
return fidoAgentAuth();
}
return { useSshAgent: false, ...forwardingAgent };
}
if (authMethod === "key" || isFidoSk) {
// Explicit key auth without agent only works for soft keys; FIDO SK
// material is routed through the agent regardless of authMethod.
if (isFidoSk) {
return fidoAgentAuth();
}
if (host.useSshAgent !== true || !hasAgentSelector) {
return { useSshAgent: false, ...forwardingAgent };
}
const agentPublicKeys = resolveAgentPublicKeys(key);
return {
useSshAgent: true,
identityAgent: host.identityAgent,
identitiesOnly: true,
addKeysToAgent: host.addKeysToAgent,
useKeychain: host.useKeychain,
...(key?.publicKey?.trim() ? { agentPublicKeys: [key.publicKey] } : {}),
...(agentPublicKeys ? { agentPublicKeys } : {}),
};
}
if (host.useSshAgent !== true) {
@@ -294,13 +374,14 @@ export const resolveBridgeSshAgentAuth = (
? forwardingAgent
: { useSshAgent: false, ...forwardingAgent };
}
const agentPublicKeys = resolveAgentPublicKeys(key);
return {
useSshAgent: true,
identityAgent: host.identityAgent,
identitiesOnly: host.identitiesOnly,
addKeysToAgent: host.addKeysToAgent,
useKeychain: host.useKeychain,
...(key?.publicKey?.trim() ? { agentPublicKeys: [key.publicKey] } : {}),
...(agentPublicKeys ? { agentPublicKeys } : {}),
};
};
@@ -0,0 +1,32 @@
"use strict";
/**
* Attach a one-shot release for an owned FIDO agent / askpass lease to a
* connection-like EventEmitter (ssh2 Client, etc.).
*
* @param {import("events").EventEmitter|null|undefined} conn
* @param {{ _releaseNetcattyFidoAgent?: () => void }|null|undefined} agent
*/
function attachFidoAgentRelease(conn, agent) {
if (!conn || typeof conn.once !== "function") return;
if (!agent || typeof agent._releaseNetcattyFidoAgent !== "function") return;
let released = false;
const releaseOwned = () => {
if (released) return;
released = true;
try { agent._releaseNetcattyFidoAgent(); } catch { /* ignore */ }
};
conn.once("close", releaseOwned);
conn.once("end", releaseOwned);
const onError = error => {
if (error?.code !== "ERR_FIDO_CANCELLED") return;
// ssh2 would otherwise try another key/password after an agent failure.
// Explicitly cancelling hardware authentication must end this connection.
try { conn.end?.(); } catch { /* already closed */ }
};
conn.on("error", onError);
conn.once("close", () => conn.removeListener("error", onError));
return releaseOwned;
}
module.exports = { attachFidoAgentRelease };
@@ -0,0 +1,76 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { attachFidoAgentRelease } = require("./attachFidoAgentRelease.cjs");
test("attachFidoAgentRelease invokes release once on close", () => {
const conn = new EventEmitter();
let releaseCount = 0;
const agent = {
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
};
attachFidoAgentRelease(conn, agent);
conn.emit("close");
assert.equal(releaseCount, 1);
conn.emit("close");
assert.equal(releaseCount, 1);
});
test("attachFidoAgentRelease invokes release once on end", () => {
const conn = new EventEmitter();
let releaseCount = 0;
const agent = {
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
};
attachFidoAgentRelease(conn, agent);
conn.emit("end");
assert.equal(releaseCount, 1);
conn.emit("end");
assert.equal(releaseCount, 1);
});
test("attachFidoAgentRelease is one-shot across close and end", () => {
const conn = new EventEmitter();
let releaseCount = 0;
const agent = {
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
};
attachFidoAgentRelease(conn, agent);
conn.emit("close");
conn.emit("end");
assert.equal(releaseCount, 1);
});
test("attachFidoAgentRelease no-ops without conn or release hook", () => {
let releaseCount = 0;
const agent = {
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
};
attachFidoAgentRelease(null, agent);
attachFidoAgentRelease(new EventEmitter(), null);
attachFidoAgentRelease(new EventEmitter(), {});
assert.equal(releaseCount, 0);
});
test("explicit FIDO cancellation ends the transport instead of trying another credential", () => {
const conn = new EventEmitter();
let ended = 0;
conn.end = () => { ended++; };
attachFidoAgentRelease(conn, { _releaseNetcattyFidoAgent() {} });
conn.emit("error", Object.assign(new Error("cancelled"), { code: "ERR_FIDO_CANCELLED" }));
assert.equal(ended, 1);
conn.emit("error", new Error("ordinary agent error"));
assert.equal(ended, 1);
conn.emit("close");
assert.equal(conn.listenerCount("error"), 0);
});
+493
View File
@@ -0,0 +1,493 @@
"use strict";
/**
* Netcatty-owned ssh-agent for FIDO2 sk-* keys.
*
* System agents started at login often lack SSH_ASKPASS, so PIN/touch prompts
* never reach our GUI. Spawning a short-lived agent as our child with askpass
* env makes sk-helper prompts work inside Netcatty.
*/
const fs = require("node:fs");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const { randomUUID } = require("node:crypto");
const {
buildFidoAskpassEnv,
releaseFidoAskpassLease,
shutdownFidoAskpass,
} = require("./fidoAskpass.cjs");
const execFileAsync = promisify(execFile);
/** @type {{ killed: boolean, pid: number, kill: (sig?: string) => void }|null} */
let agentChild = null;
/** @type {string|null} */
let agentSocket = null;
/** @type {string|null} */
let agentDir = null;
let refCount = 0;
/**
* Monotonic id for the currently tracked agent. Cleared/restarted agents bump
* this so stale release callbacks from earlier acquisitions cannot kill a newer
* agent that reused the singleton slot.
*/
let agentGeneration = 0;
/** @type {Promise<{ socketPath: string, askpassEnv: Record<string, string>, owned: boolean, generation: number }>|null} */
let startingPromise = null;
function getTempBase() {
const tempDirBridge = require("./tempDirBridge.cjs");
if (typeof tempDirBridge.getTempDir !== "function") {
throw new Error("FIDO agent requires Netcatty temp directory (tempDirBridge unavailable).");
}
return tempDirBridge.getTempDir();
}
const WIN_SYSTEM_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
/**
* User-mode ssh-agent binaries on Windows (not the Win32-OpenSSH service host).
* These can run as our child with SSH_ASKPASS so verify-required sk-helper
* prompts reach the Netcatty modal.
* @param {NodeJS.ProcessEnv} [env]
* @returns {string[]}
*/
function listWindowsUserModeSshAgentCandidates(env = process.env) {
const programFiles = env.ProgramFiles || "C:\\Program Files";
const programFilesX86 = env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
const localAppData = env.LOCALAPPDATA || "";
const userProfile = env.USERPROFILE || "";
return [
path.join(programFiles, "Git", "usr", "bin", "ssh-agent.exe"),
path.join(programFilesX86, "Git", "usr", "bin", "ssh-agent.exe"),
path.join(localAppData, "Programs", "Git", "usr", "bin", "ssh-agent.exe"),
path.join(userProfile, "scoop", "apps", "git", "current", "usr", "bin", "ssh-agent.exe"),
];
}
/**
* Pair ssh-add with a user-mode ssh-agent (same directory) when possible.
* @param {string} sshAgentPath
* @returns {string|null}
*/
function companionSshAddPath(sshAgentPath) {
if (typeof sshAgentPath !== "string" || !sshAgentPath.trim()) return null;
const dir = path.dirname(sshAgentPath);
const base = path.basename(sshAgentPath).toLowerCase().replace(/\.exe$/i, "");
if (base !== "ssh-agent") return null;
const candidate = path.join(dir, path.basename(sshAgentPath).replace(/ssh-agent/i, "ssh-add"));
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// ignore
}
return null;
}
function resolveSshAgentBinary(env = process.env, platform = process.platform) {
if (typeof env.NETCATTY_SSH_AGENT_PATH === "string" && env.NETCATTY_SSH_AGENT_PATH.trim()) {
return env.NETCATTY_SSH_AGENT_PATH.trim();
}
if (platform === "darwin") {
for (const candidate of [
"/opt/homebrew/bin/ssh-agent",
"/usr/local/bin/ssh-agent",
"/usr/bin/ssh-agent",
]) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// continue
}
}
}
if (platform === "win32") {
// Prefer Git/MSYS (or similar) user-mode agents over System32's service binary.
for (const candidate of listWindowsUserModeSshAgentCandidates(env)) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// continue
}
}
}
return "ssh-agent";
}
function parseAgentStdout(stdout) {
const sockMatch = /SSH_AUTH_SOCK=([^;\s]+)/.exec(stdout || "");
const pidMatch = /SSH_AGENT_PID=(\d+)/.exec(stdout || "");
return {
socketPath: sockMatch?.[1] || null,
agentPid: pidMatch ? Number(pidMatch[1]) : null,
};
}
function isProcessAlive(pid) {
if (!pid || !Number.isFinite(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function isWindowsNamedPipe(socketPath) {
return typeof socketPath === "string"
&& /^\\\\[.,]\\pipe\\/i.test(socketPath);
}
function isAgentLive() {
if (!agentSocket) return false;
// Named pipes are not reliable with existsSync; rely on PID / child state.
if (!isWindowsNamedPipe(agentSocket)) {
try {
if (!fs.existsSync(agentSocket)) return false;
} catch {
return false;
}
}
if (agentChild?.pid) return isProcessAlive(agentChild.pid);
// Socket exists but no PID tracking — treat as live until connect fails.
// System-pipe fallback sets agentChild=null; treat that as live while socket is set.
if (!agentChild) return isWindowsNamedPipe(agentSocket);
return !agentChild.killed;
}
function clearAgentState({ kill = true } = {}) {
if (kill && agentChild) {
const ownedPid = agentChild.pid;
// Only kill OUR tracked pid. Never run `ssh-agent -k` with the ambient
// process.env SSH_AGENT_PID — that can kill the user's login agent.
try {
if (ownedPid && isProcessAlive(ownedPid)) {
process.kill(ownedPid, "TERM");
}
} catch {
// ignore
}
if (ownedPid && agentSocket) {
try {
const sshAgent = resolveSshAgentBinary();
// Isolate env: only our sock + pid, no inheritance of login agent vars.
execFile(sshAgent, ["-k"], {
env: {
PATH: process.env.PATH || "/usr/bin:/bin",
SSH_AUTH_SOCK: agentSocket,
SSH_AGENT_PID: String(ownedPid),
},
windowsHide: true,
timeout: 3000,
}, () => {});
} catch {
// ignore
}
}
try {
agentChild.kill?.("TERM");
} catch {
// ignore
}
}
agentChild = null;
agentSocket = null;
if (agentDir) {
fs.promises.rm(agentDir, { recursive: true, force: true }).catch(() => {});
agentDir = null;
}
refCount = 0;
agentGeneration += 1;
}
/**
* Env for the long-lived ssh-agent process. Must not include a caller-bound
* NETCATTY_FIDO_ASKPASS_LEASE: ssh-sk-helper for later verify-required signing
* is a descendant of the agent and would inherit that lease, routing a second
* window's PIN/touch prompt to the starter forever. Callers still receive a
* per-acquire lease via askpassEnv for ssh-add. Agent-spawned prompts route to
* the last leased signing window in fidoAskpass (not the sticky last acquire).
* @param {Record<string, string>|null|undefined} askpassEnv
* @returns {Record<string, string>}
*/
function askpassEnvForAgentProcess(askpassEnv) {
if (!askpassEnv || typeof askpassEnv !== "object") return {};
const { NETCATTY_FIDO_ASKPASS_LEASE: _callerLease, ...agentAskpassEnv } = askpassEnv;
return agentAskpassEnv;
}
/**
* Acquire a Netcatty FIDO agent socket. Multiple callers share one agent.
* Concurrent acquires are serialized via startingPromise.
*/
async function acquireFidoAgent(options = {}) {
if (startingPromise) {
const shared = await startingPromise;
if (isAgentLive()) {
refCount += 1;
// Caller-specific askpass lease — never reuse the starter's resolver.
return {
socketPath: shared.socketPath,
askpassEnv: buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents }),
owned: shared.owned,
generation: shared.generation,
sshAddPath: shared.sshAddPath || null,
};
}
}
if (isAgentLive() && agentSocket) {
refCount += 1;
return {
socketPath: agentSocket,
askpassEnv: buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents }),
// Preserve ownership of the live singleton (Windows system-pipe fallback is not owned).
owned: Boolean(agentChild),
generation: agentGeneration,
sshAddPath: companionSshAddPath(resolveSshAgentBinary(options.env || process.env, options.platform || process.platform)),
};
}
// Dead leftover state
if (agentSocket || agentChild) clearAgentState({ kill: true });
startingPromise = (async () => {
const run = options.execFile || execFileAsync;
const env = options.env || process.env;
const askpassEnv = buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents });
const platform = options.platform || process.platform;
const releaseStarterAskpassLease = () => {
try {
releaseFidoAskpassLease(askpassEnv?.NETCATTY_FIDO_ASKPASS_LEASE);
} catch {
// ignore
}
};
try {
const sshAgent = resolveSshAgentBinary(env, platform);
const sshAddPath = companionSshAddPath(sshAgent);
agentDir = path.join(getTempBase(), `netcatty-fido-agent-${randomUUID().slice(0, 8)}`);
fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 });
// Keep SSH_ASKPASS on the agent so verify-required sk-helper prompts work,
// but never bake the starter's caller lease into the shared process env.
const agentProcessEnv = {
...env, ...askpassEnvForAgentProcess(askpassEnv),
...require("./fidoSkHelper.cjs").buildSkHelperEnv(sshAgent, askpassEnv, env, platform),
};
// Windows: try a Netcatty-owned named pipe first (ssh2 speaks it natively),
// then a filesystem socket for MSYS/Git user-mode agents.
const sockCandidates = platform === "win32"
? [
`\\\\.\\pipe\\netcatty-fido-agent-${randomUUID().slice(0, 8)}`,
path.join(agentDir, "agent.sock"),
]
: [path.join(agentDir, "agent.sock")];
let stdout = "";
let boundSockPath = sockCandidates[0];
let started = false;
/** @type {Error|null} */
let lastStartError = null;
for (const sockPath of sockCandidates) {
boundSockPath = sockPath;
try {
const result = await run(sshAgent, ["-a", sockPath, "-s"], {
timeout: 10000,
windowsHide: true,
env: agentProcessEnv,
});
stdout = result.stdout?.toString?.() || result.stdout || "";
started = true;
break;
} catch (error) {
lastStartError = error instanceof Error ? error : new Error(String(error));
}
}
if (!started) {
try {
const result = await run(sshAgent, ["-s"], {
timeout: 10000,
windowsHide: true,
env: agentProcessEnv,
});
stdout = result.stdout?.toString?.() || result.stdout || "";
started = true;
} catch (fallbackError) {
lastStartError = fallbackError instanceof Error
? fallbackError
: new Error(String(fallbackError));
}
}
if (started) {
const parsed = parseAgentStdout(stdout);
const fallbackSock = isWindowsNamedPipe(boundSockPath)
? boundSockPath
: (fs.existsSync(boundSockPath) ? boundSockPath : null);
agentSocket = parsed.socketPath || fallbackSock;
if (!agentSocket) {
clearAgentState({ kill: true });
const err = new Error("ssh-agent started but SSH_AUTH_SOCK was not reported.");
err.code = "ERR_FIDO_AGENT_SOCK";
throw err;
}
// In-box Win32-OpenSSH `ssh-agent -s` often just advertises the system
// service pipe — that is not a Netcatty child and must not be killed.
const isSystemServicePipe = typeof agentSocket === "string"
&& /openssh-ssh-agent$/i.test(agentSocket.replace(/\//g, "\\"));
if (isSystemServicePipe) {
agentChild = null;
agentDir = null;
refCount = 1;
return {
socketPath: agentSocket,
askpassEnv,
owned: false,
generation: agentGeneration,
sshAddPath: null,
};
}
if (parsed.agentPid && Number.isFinite(parsed.agentPid) && parsed.agentPid > 0) {
const pid = parsed.agentPid;
agentChild = {
killed: false,
pid,
kill(sig = "TERM") {
try { process.kill(pid, sig); } catch { /* ignore */ }
this.killed = true;
},
};
} else {
// No PID — still usable via socket; kill via ssh-agent -k on release.
agentChild = {
killed: false,
pid: 0,
kill() { this.killed = true; },
};
}
refCount = 1;
return {
socketPath: agentSocket,
askpassEnv,
owned: true,
generation: agentGeneration,
sshAddPath,
};
}
// Win32-OpenSSH's in-box ssh-agent is a service bound to a fixed named
// pipe and usually rejects `ssh-agent -a`. Fall back to that pipe so
// non-verify-required keys still work, but mark owned:false so callers
// remove any identities they ssh-add (see prepareSystemSshAgentForAuth).
// verify-required signing still needs a prompt-capable child agent above.
if (platform === "win32") {
clearAgentState({ kill: false });
agentSocket = WIN_SYSTEM_AGENT_PIPE;
agentChild = null;
agentDir = null;
refCount = 1;
return {
socketPath: WIN_SYSTEM_AGENT_PIPE,
askpassEnv,
owned: false,
generation: agentGeneration,
sshAddPath: null,
};
}
clearAgentState({ kill: false });
const message = lastStartError instanceof Error ? lastStartError.message : String(lastStartError || "unknown");
const err = new Error(
`Could not start a FIDO-capable ssh-agent (${message}). Install OpenSSH with libfido2 (macOS: brew install openssh libfido2).`,
);
err.code = "ERR_FIDO_AGENT_START";
throw err;
} catch (error) {
// Starter allocated a caller-specific askpass lease before launch; drop it
// whenever startup fails so WebContents resolvers are not retained.
releaseStarterAskpassLease();
throw error;
}
})();
try {
return await startingPromise;
} finally {
startingPromise = null;
}
}
/**
* Release one acquisition. Pass the `generation` returned by acquireFidoAgent so
* a late close after the agent was replaced cannot decrement/kill the newer agent.
*/
function releaseFidoAgent(generation) {
if (generation !== agentGeneration) return;
refCount = Math.max(0, refCount - 1);
if (refCount > 0) return;
clearAgentState({ kill: true });
}
function getActiveFidoAgentSocket() {
return isAgentLive() ? agentSocket : null;
}
function shutdownFidoAgentSubsystem() {
clearAgentState({ kill: true });
try {
shutdownFidoAskpass();
} catch {
// ignore
}
}
let quitHookInstalled = false;
function installFidoAgentQuitHook() {
if (quitHookInstalled) return;
quitHookInstalled = true;
const shutdown = () => {
try { shutdownFidoAgentSubsystem(); } catch { /* ignore */ }
};
// Terminal/auth often runs in a utilityProcess where `app` is unavailable.
// Use only the `exit` hook so we do not swallow SIGTERM/SIGINT default exit
// behavior (installing those handlers without process.exit leaves workers hung).
process.once("exit", shutdown);
try {
const { app } = require("electron");
app.once("before-quit", shutdown);
app.once("will-quit", shutdown);
} catch {
// non-electron / utility process
}
}
// Best-effort install when module loads in Electron main or worker.
try { installFidoAgentQuitHook(); } catch { /* ignore */ }
module.exports = {
acquireFidoAgent,
releaseFidoAgent,
getActiveFidoAgentSocket,
resolveSshAgentBinary,
parseAgentStdout,
shutdownFidoAgentSubsystem,
installFidoAgentQuitHook,
isAgentLive,
askpassEnvForAgentProcess,
listWindowsUserModeSshAgentCandidates,
companionSshAddPath,
WIN_SYSTEM_AGENT_PIPE,
// exposed for tests
getTempBase,
};
+298
View File
@@ -0,0 +1,298 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
parseAgentStdout,
isAgentLive,
releaseFidoAgent,
getActiveFidoAgentSocket,
shutdownFidoAgentSubsystem,
acquireFidoAgent,
getTempBase,
} = require("./fidoAgentManager.cjs");
test("parseAgentStdout extracts sock and pid", () => {
const parsed = parseAgentStdout(
"SSH_AUTH_SOCK=/tmp/agent.123; export SSH_AUTH_SOCK;\nSSH_AGENT_PID=9999; export SSH_AGENT_PID;\n",
);
assert.equal(parsed.socketPath, "/tmp/agent.123");
assert.equal(parsed.agentPid, 9999);
});
test("getTempBase uses Netcatty managed temp dir (no os.tmpdir fallback)", () => {
const tempDirBridge = require("./tempDirBridge.cjs");
const managed = tempDirBridge.getTempDir();
assert.equal(getTempBase(), managed);
assert.match(managed, /Netcatty/i);
});
test("release without acquire is safe", () => {
releaseFidoAgent();
releaseFidoAgent();
assert.equal(getActiveFidoAgentSocket(), null);
assert.equal(isAgentLive(), false);
shutdownFidoAgentSubsystem();
});
async function withManagedTemp(run) {
const fs = require("node:fs");
const path = require("node:path");
const tempDirBridge = require("./tempDirBridge.cjs");
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fa-"));
const originalGetTempDir = tempDirBridge.getTempDir;
tempDirBridge.getTempDir = () => managedTemp;
try {
return await run(managedTemp);
} finally {
tempDirBridge.getTempDir = originalGetTempDir;
shutdownFidoAgentSubsystem();
fs.rmSync(managedTemp, { recursive: true, force: true });
}
}
test("acquireFidoAgent on win32 starts a prompt-capable child agent when -a works", async () => {
await withManagedTemp(async () => {
shutdownFidoAgentSubsystem();
const pipe = "\\\\.\\pipe\\netcatty-fido-agent-testown";
const agent = await acquireFidoAgent({
platform: "win32",
resolveWebContents: () => null,
env: { PATH: process.env.PATH || "", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async (_bin, args, opts) => {
assert.ok(opts?.env?.SSH_ASKPASS, "child agent must inherit askpass");
assert.equal(opts?.env?.NETCATTY_FIDO_ASKPASS_LEASE, undefined);
assert.ok(Array.isArray(args) && args.includes("-a"));
return {
stdout: `SSH_AUTH_SOCK=${pipe}; export SSH_AUTH_SOCK;\nSSH_AGENT_PID=4242; export SSH_AGENT_PID;\n`,
};
},
});
assert.equal(agent.socketPath, pipe);
assert.equal(agent.owned, true);
assert.ok(agent.askpassEnv?.SSH_ASKPASS);
releaseFidoAgent(agent.generation);
});
});
test("acquireFidoAgent on win32 falls back to system pipe when child agent cannot start", async () => {
await withManagedTemp(async () => {
shutdownFidoAgentSubsystem();
const agent = await acquireFidoAgent({
platform: "win32",
resolveWebContents: () => null,
env: { PATH: process.env.PATH || "", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async () => {
throw new Error("ssh-agent -a unsupported");
},
});
assert.equal(agent.socketPath, "\\\\.\\pipe\\openssh-ssh-agent");
assert.equal(agent.owned, false);
assert.ok(agent.askpassEnv?.SSH_ASKPASS);
releaseFidoAgent(agent.generation);
});
});
test("acquireFidoAgent on win32 does not claim ownership of the system service pipe", async () => {
await withManagedTemp(async () => {
shutdownFidoAgentSubsystem();
const systemPipe = "\\\\.\\pipe\\openssh-ssh-agent";
const agent = await acquireFidoAgent({
platform: "win32",
resolveWebContents: () => null,
env: { PATH: process.env.PATH || "", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async () => ({
stdout: `SSH_AUTH_SOCK=${systemPipe}; export SSH_AUTH_SOCK;\n`,
}),
});
assert.equal(agent.socketPath, systemPipe);
assert.equal(agent.owned, false);
releaseFidoAgent(agent.generation);
});
});
test("acquireFidoAgent releases askpass lease when agent start fails", async () => {
shutdownFidoAgentSubsystem();
const fs = require("node:fs");
const path = require("node:path");
const tempDirBridge = require("./tempDirBridge.cjs");
const { getAskpassLeaseCountForTests } = require("./fidoAskpass.cjs");
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fa-"));
const originalGetTempDir = tempDirBridge.getTempDir;
tempDirBridge.getTempDir = () => managedTemp;
const before = getAskpassLeaseCountForTests();
try {
await assert.rejects(
() => acquireFidoAgent({
platform: "linux",
resolveWebContents: () => ({ id: "fail-start" }),
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async () => {
throw new Error("ssh-agent missing");
},
}),
(error) => error?.code === "ERR_FIDO_AGENT_START",
);
assert.equal(getAskpassLeaseCountForTests(), before);
} finally {
tempDirBridge.getTempDir = originalGetTempDir;
shutdownFidoAgentSubsystem();
fs.rmSync(managedTemp, { recursive: true, force: true });
}
});
test("askpassEnvForAgentProcess strips caller-bound askpass lease", () => {
const { askpassEnvForAgentProcess } = require("./fidoAgentManager.cjs");
const stripped = askpassEnvForAgentProcess({
SSH_ASKPASS: "/tmp/askpass.sh",
SSH_ASKPASS_REQUIRE: "force",
NETCATTY_FIDO_ASKPASS_SOCK: "/tmp/askpass.sock",
NETCATTY_FIDO_ASKPASS_LEASE: "starter-lease",
});
assert.equal(stripped.SSH_ASKPASS, "/tmp/askpass.sh");
assert.equal(stripped.NETCATTY_FIDO_ASKPASS_SOCK, "/tmp/askpass.sock");
assert.equal(stripped.SSH_ASKPASS_REQUIRE, "force");
assert.equal("NETCATTY_FIDO_ASKPASS_LEASE" in stripped, false);
});
test("ssh-agent spawn env omits caller-bound askpass lease", async () => {
shutdownFidoAgentSubsystem();
const fs = require("node:fs");
const path = require("node:path");
const tempDirBridge = require("./tempDirBridge.cjs");
const { releaseFidoAskpassLease } = require("./fidoAskpass.cjs");
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fa-"));
const originalGetTempDir = tempDirBridge.getTempDir;
tempDirBridge.getTempDir = () => managedTemp;
const sockPath = path.join(managedTemp, "agent.sock");
fs.writeFileSync(sockPath, "");
/** @type {Record<string, string>|null} */
let spawnEnv = null;
try {
const acquired = await acquireFidoAgent({
platform: "linux",
resolveWebContents: () => ({ id: "starter" }),
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async (_bin, _args, opts) => {
spawnEnv = opts?.env || null;
return {
stdout: `SSH_AUTH_SOCK=${sockPath}; export SSH_AUTH_SOCK;\n`,
};
},
});
assert.ok(spawnEnv);
assert.ok(spawnEnv.SSH_ASKPASS);
assert.equal(spawnEnv.NETCATTY_FIDO_ASKPASS_LEASE, undefined);
assert.ok(acquired.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
releaseFidoAskpassLease(acquired.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
releaseFidoAgent(acquired.generation);
} finally {
tempDirBridge.getTempDir = originalGetTempDir;
shutdownFidoAgentSubsystem();
fs.rmSync(managedTemp, { recursive: true, force: true });
}
});
test("concurrent acquireFidoAgent returns a fresh askpass lease per caller", async () => {
await withManagedTemp(async (managedTemp) => {
shutdownFidoAgentSubsystem();
const fs = require("node:fs");
const path = require("node:path");
const { releaseFidoAskpassLease } = require("./fidoAskpass.cjs");
const sockPath = path.join(managedTemp, "agent.sock");
fs.writeFileSync(sockPath, "");
let releaseStart;
const startGate = new Promise((resolve) => { releaseStart = resolve; });
const first = acquireFidoAgent({
platform: "linux",
resolveWebContents: () => ({ id: "first" }),
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async () => {
releaseStart();
await new Promise((r) => setTimeout(r, 80));
return {
stdout: `SSH_AUTH_SOCK=${sockPath}; export SSH_AUTH_SOCK;\n`,
};
},
});
await startGate;
const second = acquireFidoAgent({
platform: "linux",
resolveWebContents: () => ({ id: "second" }),
execFile: async () => {
throw new Error("second caller must await startingPromise");
},
});
const [a, b] = await Promise.all([first, second]);
assert.equal(a.socketPath, sockPath);
assert.equal(b.socketPath, sockPath);
assert.notEqual(
a.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE,
b.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE,
);
releaseFidoAskpassLease(a.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
releaseFidoAskpassLease(b.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
releaseFidoAgent(a.generation);
releaseFidoAgent(b.generation);
});
});
test("stale releaseFidoAgent ignores a newer agent generation", async () => {
shutdownFidoAgentSubsystem();
const fs = require("node:fs");
const path = require("node:path");
const tempDirBridge = require("./tempDirBridge.cjs");
const { releaseFidoAskpassLease } = require("./fidoAskpass.cjs");
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fa-"));
const originalGetTempDir = tempDirBridge.getTempDir;
tempDirBridge.getTempDir = () => managedTemp;
const firstSock = path.join(managedTemp, "agent1.sock");
const secondSock = path.join(managedTemp, "agent2.sock");
fs.writeFileSync(firstSock, "");
fs.writeFileSync(secondSock, "");
try {
const first = await acquireFidoAgent({
platform: "linux",
resolveWebContents: () => ({ id: "gen-first" }),
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async () => ({
stdout: `SSH_AUTH_SOCK=${firstSock}; export SSH_AUTH_SOCK;\n`,
}),
});
assert.equal(getActiveFidoAgentSocket(), firstSock);
// Simulate the managed agent dying while a connection still holds a release.
fs.rmSync(firstSock, { force: true });
assert.equal(isAgentLive(), false);
const second = await acquireFidoAgent({
platform: "linux",
resolveWebContents: () => ({ id: "gen-second" }),
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async () => ({
stdout: `SSH_AUTH_SOCK=${secondSock}; export SSH_AUTH_SOCK;\n`,
}),
});
assert.notEqual(first.generation, second.generation);
assert.equal(getActiveFidoAgentSocket(), secondSock);
// Late close of the old connection must not kill the replacement agent.
releaseFidoAgent(first.generation);
assert.equal(getActiveFidoAgentSocket(), secondSock);
assert.equal(isAgentLive(), true);
releaseFidoAskpassLease(first.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
releaseFidoAskpassLease(second.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
releaseFidoAgent(second.generation);
assert.equal(getActiveFidoAgentSocket(), null);
} finally {
tempDirBridge.getTempDir = originalGetTempDir;
shutdownFidoAgentSubsystem();
fs.rmSync(managedTemp, { recursive: true, force: true });
}
});
+498
View File
@@ -0,0 +1,498 @@
"use strict";
/**
* SSH_ASKPASS bridge for FIDO2 (ssh-keygen / ssh-add / ssh-sk-helper).
*
* OpenSSH invokes SSH_ASKPASS as a subprocess with the prompt text as argv.
* The helper connects to a Netcatty-owned IPC socket; main process shows a
* native PIN/touch modal and returns the response on stdout of the helper.
*/
const fs = require("node:fs");
const net = require("node:net");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const fidoPromptHandler = require("./fidoPromptHandler.cjs");
const FIDO_ASKPASS_SCRIPT = String.raw`#!/usr/bin/env node
"use strict";
const net = require("node:net");
const fs = require("node:fs");
const sockPath = process.env.NETCATTY_FIDO_ASKPASS_SOCK;
const leaseId = process.env.NETCATTY_FIDO_ASKPASS_LEASE || "";
const prompt = process.argv.slice(2).join(" ") || process.env.SSH_ASKPASS_PROMPT || "";
function fail(code) {
process.exit(code == null ? 1 : code);
}
if (!sockPath) fail(1);
const payload = JSON.stringify({ prompt, type: "askpass", leaseId,
mode: process.env.SSH_ASKPASS_PROMPT || "" }) + "\n";
const client = net.createConnection(sockPath);
let buf = "";
let settled = false;
function finish(ok, text) {
if (settled) return;
settled = true;
try { client.end(); } catch { /* ignore */ }
if (!ok) fail(1);
process.stdout.write(text == null ? "" : String(text));
process.stdout.write("\n");
process.exit(0);
}
client.setEncoding("utf8");
client.on("connect", () => {
client.write(payload);
});
client.on("data", (chunk) => {
buf += chunk;
const nl = buf.indexOf("\n");
if (nl === -1) return;
let msg;
try {
msg = JSON.parse(buf.slice(0, nl));
} catch {
finish(false);
return;
}
if (msg && msg.ok === true) finish(true, msg.response || "");
else finish(false);
});
client.on("error", () => finish(false));
client.on("end", () => {
if (!settled) finish(false);
});
setTimeout(() => finish(false), 170000);
`;
/** @type {net.Server|null} */
let askpassServer = null;
/** @type {string|null} */
let askpassSocketPath = null;
/** @type {string|null} */
let askpassScriptPath = null;
/** @type {string|null} */
let askpassWrapperPath = null;
/** @type {(() => import("electron").WebContents|null)|null} */
let resolveWebContents = null;
/** @type {Map<string, () => import("electron").WebContents|null>} */
const askpassLeases = new Map();
/**
* Last resolver that successfully handled a caller-bound (leased) askpass
* prompt. Agent-spawned ssh-sk-helper has no lease after we strip the starter
* lease from the shared agent env; route those prompts to this signing window
* instead of the sticky last-acquire global resolver (wrong under multi-window
* / terminal-worker where BrowserWindow focus is unavailable).
* @type {(() => import("electron").WebContents|null)|null}
*/
let lastLeasedSigningResolver = null;
/** @type {string|null} Lease id that last marked lastLeasedSigningResolver. */
let lastLeasedSigningLeaseId = null;
let activeSigningLeaseId = null;
const signingQueue = [];
let helperTimeoutMs = 30000;
function setHelperTimeoutForTests(value) {
if (value != null && (!Number.isFinite(value) || value <= 0)) throw new Error("Invalid FIDO helper timeout");
helperTimeoutMs = value == null ? 30000 : value;
}
function observeSkHelper(socket, entry, initial = "") {
if (!entry) { socket.end('{"ack":true}\n'); return; }
if (entry.controller.signal.aborted) { socket.end('{"cancel":true}\n'); return; }
let buffer = initial;
const timer = setTimeout(() => {
entry.timedOut = true;
if (!socket.destroyed) socket.write('{"cancel":true}\n');
}, helperTimeoutMs);
const cancelHelper = () => {
if (!socket.destroyed) socket.write('{"cancel":true}\n');
};
const onAbort = cancelHelper;
entry.controller.signal.addEventListener("abort", onAbort, { once: true });
const consume = chunk => {
buffer += chunk;
const nl = buffer.indexOf("\n");
if (nl === -1) return;
let result;
try { result = JSON.parse(buffer.slice(0, nl)); } catch { socket.destroy(); return; }
if (result.phase !== "result") return;
clearTimeout(timer);
entry.helperError = Number.isInteger(result.errorCode) ? result.errorCode : null;
socket.end('{"ack":true}\n');
};
socket.on("data", consume);
socket.once("close", () => {
clearTimeout(timer);
entry.controller.signal.removeEventListener("abort", onAbort);
});
if (buffer) consume("");
}
// A shared OpenSSH agent cannot pass a per-request lease to its helper.
// Serialize hardware signatures and bind each prompt to its calling window
// until that signature's callback settles.
function runWithFidoSigningLease(leaseId, operation) {
signingQueue.push({ leaseId, operation, controller: new AbortController() });
if (signingQueue.length > 1) return;
const next = () => {
const entry = signingQueue[0];
if (!entry) return;
activeSigningLeaseId = entry.leaseId;
let finished = false;
entry.operation(() => {
if (finished) return;
finished = true;
entry.attemptController?.abort();
entry.controller.abort();
activeSigningLeaseId = null;
signingQueue.shift();
if (signingQueue.length) queueMicrotask(next);
}, entry);
};
next();
}
function getTempBase() {
const tempDirBridge = require("./tempDirBridge.cjs");
if (typeof tempDirBridge.getTempDir !== "function") {
throw new Error("FIDO askpass requires Netcatty temp directory (tempDirBridge unavailable).");
}
return tempDirBridge.getTempDir();
}
function isWindowsNamedPipePath(socketPath) {
return typeof socketPath === "string"
&& /^\\\\[.,]\\pipe\\/i.test(socketPath);
}
/**
* Askpass IPC address. Windows Node `net.Server` only binds named pipes under
* `\\.\pipe\...` — a filesystem `askpass.sock` path fails to listen.
* @param {string} baseDir
* @param {NodeJS.Platform} [platform]
*/
function resolveFidoAskpassSocketPath(baseDir, platform = process.platform) {
if (platform === "win32") {
return `\\\\.\\pipe\\netcatty-fido-askpass-${randomUUID().slice(0, 8)}`;
}
return path.join(baseDir, "askpass.sock");
}
function writeSecureFile(filePath, contents, mode = 0o700) {
fs.writeFileSync(filePath, contents, { mode });
try {
fs.chmodSync(filePath, mode);
} catch {
// Windows may ignore mode
}
}
function defaultResolveWebContents() {
try {
const { BrowserWindow } = require("electron");
const focused = BrowserWindow.getFocusedWindow();
if (focused && !focused.isDestroyed()) return focused.webContents;
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed() && win.webContents && !win.webContents.isDestroyed()) {
return win.webContents;
}
}
} catch {
// not in electron
}
return null;
}
function liveSenderFromResolver(resolver) {
if (typeof resolver !== "function") return null;
try {
const sender = resolver();
if (sender && !sender.isDestroyed?.()) return sender;
} catch {
// ignore dead resolvers
}
return null;
}
/**
* Resolve the WebContents for agent-spawned (leaseless) askpass prompts.
* Prefer the window that most recently completed a leased signing prompt, then
* focus / any open window, then the sticky global fallback.
*/
function resolveSharedAgentPromptSender() {
// An active but released/dead caller must not fall through to another window.
if (activeSigningLeaseId) {
return liveSenderFromResolver(askpassLeases.get(activeSigningLeaseId));
}
const fromLastLease = liveSenderFromResolver(lastLeasedSigningResolver);
if (fromLastLease) return fromLastLease;
for (const resolver of askpassLeases.values()) {
const sender = liveSenderFromResolver(resolver);
if (sender) return sender;
}
return defaultResolveWebContents()
|| liveSenderFromResolver(resolveWebContents);
}
function handleAskpassClient(socket) {
let buf = "";
let handled = false;
const controller = new AbortController();
// OpenSSH kills its notification helper as soon as the token is touched.
// Dismiss that prompt even when the renderer has not clicked Continue.
socket.once("close", () => controller.abort());
socket.on("error", () => controller.abort());
socket.setEncoding("utf8");
socket.on("data", async (chunk) => {
if (handled) return;
buf += chunk;
const nl = buf.indexOf("\n");
if (nl === -1) return;
handled = true;
let msg;
try {
msg = JSON.parse(buf.slice(0, nl));
} catch {
socket.end(`${JSON.stringify({ ok: false, error: "bad_json" })}\n`);
return;
}
if (msg?.type === "sk-helper") {
observeSkHelper(socket, signingQueue[0], buf.slice(nl + 1));
return;
}
const prompt = String(msg?.prompt || "");
// OpenSSH notify_start() uses mode=none: hardware completion, not a UI
// answer, authorizes the signature. Exiting this helper only hides the hint.
const notificationOnly = msg?.mode === "none";
const kind = notificationOnly ? "touch"
: msg?.mode === "confirm" ? "confirm"
: fidoPromptHandler.classifyAskpassPrompt(prompt);
const leaseId = typeof msg?.leaseId === "string" ? msg.leaseId : "";
const leaseResolver = leaseId ? askpassLeases.get(leaseId) : null;
// Caller-bound leases (ssh-add) win and mark the active signing window.
// Agent-spawned ssh-sk-helper has no lease (shared agent must not inherit a
// starter lease) — route to that signing window, not the last acquire.
// Tag leaseless prompts with the last signing lease so teardown can cancel.
let sender = null;
/** @type {string} */
let ownerLeaseId = "";
if (leaseResolver) {
sender = liveSenderFromResolver(leaseResolver);
if (sender) {
lastLeasedSigningResolver = leaseResolver;
lastLeasedSigningLeaseId = leaseId;
ownerLeaseId = leaseId;
}
} else {
sender = resolveSharedAgentPromptSender();
ownerLeaseId = activeSigningLeaseId || lastLeasedSigningLeaseId || "";
}
if (!sender) {
socket.end(`${JSON.stringify({ ok: false, error: "no_window" })}\n`);
return;
}
const signing = signingQueue[0]?.leaseId === ownerLeaseId ? signingQueue[0] : null;
try {
const result = await fidoPromptHandler.requestFidoPrompt(sender, {
kind,
notificationOnly,
message: prompt,
keyName: signing?.keyName || "",
leaseId: ownerLeaseId,
signal: controller.signal,
});
if (socket.destroyed) return;
if (!result || result.cancelled) {
if (kind === "pin" && signing) signing.cancelled = true;
socket.end(`${JSON.stringify({ ok: false, error: "cancelled" })}\n`);
return;
}
// Touch/confirm: empty string is fine; PIN: return entered secret.
const response = kind === "pin" ? (result.response || "") : "";
if (kind === "pin" && signing) signing.pinSubmitted = true;
if (kind === "pin" && response && /user presence|touch|tap/i.test(prompt)
&& signing?.leaseId === ownerLeaseId && !signing.controller.signal.aborted) {
// ssh-agent ends its initial notification before asking for a PIN and
// does not restart it on retry. Keep a device-only waiting hint visible
// after PIN submission until the actual signature callback completes.
void fidoPromptHandler.requestFidoPrompt(sender, {
kind: "touch", notificationOnly: true, message: prompt, keyName: signing.keyName || "",
leaseId: ownerLeaseId, signal: (signing.attemptController || signing.controller).signal,
});
}
socket.end(`${JSON.stringify({ ok: true, response })}\n`);
} catch (err) {
socket.end(`${JSON.stringify({
ok: false,
error: err instanceof Error ? err.message : String(err),
})}\n`);
}
});
}
/**
* Ensure askpass server + helper scripts exist.
* @param {{ resolveWebContents?: () => import("electron").WebContents|null }} [options]
*/
function ensureFidoAskpass(options = {}) {
if (options.resolveWebContents) resolveWebContents = options.resolveWebContents;
if (askpassServer && askpassSocketPath && askpassWrapperPath) {
return {
socketPath: askpassSocketPath,
scriptPath: askpassScriptPath,
wrapperPath: askpassWrapperPath,
};
}
const base = path.join(getTempBase(), `netcatty-fido-askpass-${randomUUID().slice(0, 8)}`);
fs.mkdirSync(base, { recursive: true, mode: 0o700 });
askpassSocketPath = resolveFidoAskpassSocketPath(base);
askpassScriptPath = path.join(base, "netcatty-fido-askpass.cjs");
writeSecureFile(askpassScriptPath, FIDO_ASKPASS_SCRIPT, 0o700);
if (process.platform === "win32") {
askpassWrapperPath = path.join(base, "netcatty-fido-askpass.cmd");
writeSecureFile(
askpassWrapperPath,
`@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\n"${process.execPath.replace(/"/g, '""')}" "${askpassScriptPath.replace(/"/g, '""')}" %*\r\n`,
0o700,
);
} else {
askpassWrapperPath = path.join(base, "netcatty-fido-askpass.sh");
const electronExec = JSON.stringify(process.execPath);
const scriptExec = JSON.stringify(askpassScriptPath);
writeSecureFile(
askpassWrapperPath,
`#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${electronExec} ${scriptExec} "$@"\n`,
0o700,
);
}
if (!isWindowsNamedPipePath(askpassSocketPath) && fs.existsSync(askpassSocketPath)) {
try { fs.unlinkSync(askpassSocketPath); } catch { /* ignore */ }
}
askpassServer = net.createServer(handleAskpassClient);
askpassServer.on("error", (err) => {
console.error("[FidoAskpass] server error:", err instanceof Error ? err.message : err);
});
askpassServer.listen(askpassSocketPath);
if (!isWindowsNamedPipePath(askpassSocketPath)) {
try {
fs.chmodSync(askpassSocketPath, 0o600);
} catch {
// ignore
}
}
return {
socketPath: askpassSocketPath,
scriptPath: askpassScriptPath,
wrapperPath: askpassWrapperPath,
};
}
/**
* Environment for ssh-agent / ssh-add / ssh-keygen so PIN/touch use Netcatty UI.
* @param {{ resolveWebContents?: () => import("electron").WebContents|null }} [options]
*/
function buildFidoAskpassEnv(options = {}) {
const artifacts = ensureFidoAskpass(options);
const leaseId = randomUUID().slice(0, 12);
if (typeof options.resolveWebContents === "function") {
askpassLeases.set(leaseId, options.resolveWebContents);
}
const env = {
SSH_ASKPASS: artifacts.wrapperPath,
SSH_ASKPASS_REQUIRE: "force",
NETCATTY_FIDO_ASKPASS_SOCK: artifacts.socketPath,
NETCATTY_FIDO_ASKPASS_LEASE: leaseId,
// OpenSSH only runs askpass when no TTY unless REQUIRE=force; still set DISPLAY on Linux.
...(process.platform === "linux" && !process.env.DISPLAY
? { DISPLAY: process.env.DISPLAY || ":0" }
: {}),
};
return env;
}
function releaseFidoAskpassLease(leaseId) {
if (typeof leaseId !== "string" || !leaseId) return;
const released = askpassLeases.get(leaseId);
askpassLeases.delete(leaseId);
for (const entry of signingQueue) {
if (entry.leaseId === leaseId) entry.controller.abort();
}
try {
fidoPromptHandler.cancelFidoPromptRequestsForLease(leaseId, "lease-released");
} catch {
// ignore
}
if (released && lastLeasedSigningResolver === released) {
lastLeasedSigningResolver = null;
lastLeasedSigningLeaseId = null;
for (const [otherLeaseId, resolver] of askpassLeases.entries()) {
if (liveSenderFromResolver(resolver)) {
lastLeasedSigningResolver = resolver;
lastLeasedSigningLeaseId = otherLeaseId;
break;
}
}
} else if (lastLeasedSigningLeaseId === leaseId) {
lastLeasedSigningLeaseId = null;
}
}
function shutdownFidoAskpass() {
try {
askpassServer?.close();
} catch {
// ignore
}
askpassServer = null;
askpassSocketPath = null;
askpassScriptPath = null;
askpassWrapperPath = null;
const outstandingLeaseIds = [...askpassLeases.keys()];
askpassLeases.clear();
lastLeasedSigningResolver = null;
lastLeasedSigningLeaseId = null;
for (const leaseId of outstandingLeaseIds) {
try {
fidoPromptHandler.cancelFidoPromptRequestsForLease(leaseId, "askpass-shutdown");
} catch {
// ignore
}
}
}
module.exports = {
ensureFidoAskpass,
buildFidoAskpassEnv,
runWithFidoSigningLease,
setHelperTimeoutForTests,
releaseFidoAskpassLease,
shutdownFidoAskpass,
classifyAskpassPrompt: fidoPromptHandler.classifyAskpassPrompt,
resolveFidoAskpassSocketPath,
isWindowsNamedPipePath,
// exposed for tests
getTempBase,
handleAskpassClient,
FIDO_ASKPASS_SCRIPT,
setResolveWebContentsForTests(resolver) {
resolveWebContents = typeof resolver === "function" ? resolver : null;
},
getAskpassLeaseCountForTests() {
return askpassLeases.size;
},
};
+319
View File
@@ -0,0 +1,319 @@
"use strict";
/**
* Simulated FIDO askpass verification (no hardware).
*
* Exercises the OpenSSH SSH_ASKPASS helper → Netcatty IPC socket → prompt
* handler → PIN response path that real ssh-sk-helper would trigger.
*/
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const tempDirBridge = require("./tempDirBridge.cjs");
// Keep the managed temp path short: Unix domain sockets reject long sun_path.
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fp-"));
const originalGetTempDir = tempDirBridge.getTempDir;
tempDirBridge.getTempDir = () => managedTemp;
const {
buildFidoAskpassEnv,
ensureFidoAskpass,
shutdownFidoAskpass,
setResolveWebContentsForTests,
} = require("./fidoAskpass.cjs");
const fidoPromptHandler = require("./fidoPromptHandler.cjs");
test.after(() => {
tempDirBridge.getTempDir = originalGetTempDir;
try { fs.rmSync(managedTemp, { recursive: true, force: true }); } catch { /* ignore */ }
});
function runAskpassHelper({ wrapperPath, socketPath, prompt, timeoutMs = 5000, envExtra = {} }) {
return new Promise((resolve, reject) => {
const child = spawn(wrapperPath, [prompt], {
env: {
...process.env,
ELECTRON_RUN_AS_NODE: "1",
NETCATTY_FIDO_ASKPASS_SOCK: socketPath,
SSH_ASKPASS_REQUIRE: "force",
...envExtra,
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("askpass helper timed out"));
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.stderr.on("data", (chunk) => { stderr += chunk; });
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ code, stdout, stderr });
});
});
}
test("simulated FIDO askpass returns PIN through helper subprocess", async () => {
const pending = new Map();
const sender = {
id: 4242,
isDestroyed: () => false,
send(_channel, payload) {
pending.set(payload.requestId, payload);
// Auto-respond like the renderer FidoPromptModal would.
queueMicrotask(() => {
fidoPromptHandler.handleResponse(
{ sender: { id: 4242 } },
{ requestId: payload.requestId, response: "sim-pin-1234", cancelled: false },
);
});
},
};
setResolveWebContentsForTests(() => sender);
try {
const env = buildFidoAskpassEnv({ resolveWebContents: () => sender });
const artifacts = ensureFidoAskpass({ resolveWebContents: () => sender });
assert.equal(artifacts.socketPath, env.NETCATTY_FIDO_ASKPASS_SOCK);
assert.ok(env.NETCATTY_FIDO_ASKPASS_LEASE);
// Give the listen() a tick — Node can accept before the event loop settles.
await new Promise((r) => setTimeout(r, 50));
const result = await runAskpassHelper({
wrapperPath: artifacts.wrapperPath,
socketPath: artifacts.socketPath,
prompt: "Enter PIN for authenticator:",
envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: env.NETCATTY_FIDO_ASKPASS_LEASE },
});
assert.equal(result.code, 0, `stderr=${result.stderr}`);
assert.equal(result.stdout.trim(), "sim-pin-1234");
assert.ok(pending.size >= 1, "expected a prompt request to be delivered");
const delivered = [...pending.values()][0];
assert.equal(delivered.kind, "pin");
} finally {
setResolveWebContentsForTests(null);
shutdownFidoAskpass();
}
});
test("askpass leases route prompts to the originating resolver", async () => {
const seen = [];
const senderA = {
id: 100,
isDestroyed: () => false,
send(_channel, payload) {
seen.push(100);
queueMicrotask(() => {
fidoPromptHandler.handleResponse(
{ sender: { id: 100 } },
{ requestId: payload.requestId, response: "pin-a", cancelled: false },
);
});
},
};
const senderB = {
id: 200,
isDestroyed: () => false,
send(_channel, payload) {
seen.push(200);
queueMicrotask(() => {
fidoPromptHandler.handleResponse(
{ sender: { id: 200 } },
{ requestId: payload.requestId, response: "pin-b", cancelled: false },
);
});
},
};
// Last global resolver would be B; lease must still deliver to A.
setResolveWebContentsForTests(() => senderB);
try {
const envA = buildFidoAskpassEnv({ resolveWebContents: () => senderA });
buildFidoAskpassEnv({ resolveWebContents: () => senderB });
const artifacts = ensureFidoAskpass();
await new Promise((r) => setTimeout(r, 50));
const result = await runAskpassHelper({
wrapperPath: artifacts.wrapperPath,
socketPath: artifacts.socketPath,
prompt: "Enter PIN for authenticator:",
// Override lease to A's env lease id.
envExtra: {
NETCATTY_FIDO_ASKPASS_LEASE: envA.NETCATTY_FIDO_ASKPASS_LEASE,
},
});
assert.equal(result.code, 0, `stderr=${result.stderr}`);
assert.equal(result.stdout.trim(), "pin-a");
assert.deepEqual(seen, [100]);
} finally {
setResolveWebContentsForTests(null);
shutdownFidoAskpass();
}
});
test("simulated FIDO askpass touch prompt returns empty confirmation", async () => {
const sender = {
id: 4243,
isDestroyed: () => false,
send(_channel, payload) {
queueMicrotask(() => {
fidoPromptHandler.handleResponse(
{ sender: { id: 4243 } },
{ requestId: payload.requestId, response: "", cancelled: false },
);
});
},
};
setResolveWebContentsForTests(() => sender);
try {
const env = buildFidoAskpassEnv({ resolveWebContents: () => sender });
const artifacts = ensureFidoAskpass({ resolveWebContents: () => sender });
await new Promise((r) => setTimeout(r, 50));
const result = await runAskpassHelper({
wrapperPath: artifacts.wrapperPath,
socketPath: artifacts.socketPath,
prompt: "Confirm user presence for key ED25519-SK",
envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: env.NETCATTY_FIDO_ASKPASS_LEASE },
});
assert.equal(result.code, 0, `stderr=${result.stderr}`);
assert.equal(result.stdout.trim(), "");
} finally {
setResolveWebContentsForTests(null);
shutdownFidoAskpass();
}
});
test("leaseless agent prompts route to the last leased signing window", async () => {
const seen = [];
const senderA = {
id: 501,
isDestroyed: () => false,
send(_channel, payload) {
seen.push(501);
queueMicrotask(() => {
fidoPromptHandler.handleResponse(
{ sender: { id: 501 } },
{ requestId: payload.requestId, response: "pin-a", cancelled: false },
);
});
},
};
const senderB = {
id: 502,
isDestroyed: () => false,
send(_channel, payload) {
seen.push(502);
queueMicrotask(() => {
fidoPromptHandler.handleResponse(
{ sender: { id: 502 } },
{ requestId: payload.requestId, response: "pin-b", cancelled: false },
);
});
},
};
// Global / last-acquire resolver is B (terminal-worker has no BrowserWindow).
setResolveWebContentsForTests(() => senderB);
try {
const envA = buildFidoAskpassEnv({ resolveWebContents: () => senderA });
buildFidoAskpassEnv({ resolveWebContents: () => senderB });
const artifacts = ensureFidoAskpass();
await new Promise((r) => setTimeout(r, 50));
// First: leased ssh-add prompt binds the active signing window to A.
const leased = await runAskpassHelper({
wrapperPath: artifacts.wrapperPath,
socketPath: artifacts.socketPath,
prompt: "Enter PIN for authenticator:",
envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: envA.NETCATTY_FIDO_ASKPASS_LEASE },
});
assert.equal(leased.code, 0, `stderr=${leased.stderr}`);
assert.equal(leased.stdout.trim(), "pin-a");
// Then: agent-spawned ssh-sk-helper has no lease; must still reach A, not B.
const leaseless = await runAskpassHelper({
wrapperPath: artifacts.wrapperPath,
socketPath: artifacts.socketPath,
prompt: "Enter PIN for authenticator:",
envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: "" },
});
assert.equal(leaseless.code, 0, `stderr=${leaseless.stderr}`);
assert.equal(leaseless.stdout.trim(), "pin-a");
assert.deepEqual(seen, [501, 501]);
} finally {
setResolveWebContentsForTests(null);
shutdownFidoAskpass();
}
});
test("OpenSSH notification helper exit dismisses its pending touch prompt", async () => {
let delivered;
const requested = new Promise((resolve) => { delivered = resolve; });
const notifications = [];
const sender = {
id: 2823, isDestroyed: () => false,
send(channel, payload) {
notifications.push({ channel, payload });
if (channel === "netcatty:fido-prompt-request") delivered(payload);
},
};
let child;
const env = buildFidoAskpassEnv({ resolveWebContents: () => sender });
try {
child = spawn(env.SSH_ASKPASS, ["Confirm user presence for key ED25519-SK"], {
env: { ...process.env, ...env, SSH_ASKPASS_PROMPT: "none" }, stdio: "ignore",
});
const request = await requested;
assert.equal(request.kind, "touch");
assert.equal(request.notificationOnly, true);
const closed = new Promise((resolve) => child.once("close", resolve));
child.kill("SIGTERM");
await closed;
for (let i = 0; i < 50 && fidoPromptHandler.getRequests().has(request.requestId); i++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.equal(fidoPromptHandler.getRequests().has(request.requestId), false);
assert.ok(notifications.some(({ channel, payload }) =>
channel === "netcatty:fido-prompt-cancelled" && payload.requestId === request.requestId));
} finally {
child?.kill();
require("./fidoAskpass.cjs").releaseFidoAskpassLease(env.NETCATTY_FIDO_ASKPASS_LEASE);
shutdownFidoAskpass();
}
});
test("late prompts for a released signing lease never reach another window", async () => {
let otherPrompts = 0;
const owner = { id: 810, isDestroyed: () => false, send() {} };
const other = { id: 811, isDestroyed: () => false, send() { otherPrompts++; } };
const env = buildFidoAskpassEnv({ resolveWebContents: () => owner });
buildFidoAskpassEnv({ resolveWebContents: () => other });
const api = require("./fidoAskpass.cjs");
let finish;
api.runWithFidoSigningLease(env.NETCATTY_FIDO_ASKPASS_LEASE, done => { finish = done; });
api.releaseFidoAskpassLease(env.NETCATTY_FIDO_ASKPASS_LEASE);
try {
const artifacts = ensureFidoAskpass();
const result = await runAskpassHelper({
wrapperPath: artifacts.wrapperPath, socketPath: artifacts.socketPath,
prompt: "Enter PIN for authenticator:", envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: "" },
});
assert.equal(result.code, 1);
assert.equal(otherPrompts, 0);
} finally { finish(); shutdownFidoAskpass(); }
});
+56
View File
@@ -0,0 +1,56 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
classifyAskpassPrompt,
buildFidoAskpassEnv,
ensureFidoAskpass,
releaseFidoAskpassLease,
shutdownFidoAskpass,
getTempBase,
} = require("./fidoAskpass.cjs");
const fidoPromptHandler = require("./fidoPromptHandler.cjs");
const fs = require("node:fs");
test("buildFidoAskpassEnv creates helper artifacts", () => {
const env = buildFidoAskpassEnv();
assert.ok(env.SSH_ASKPASS);
assert.equal(env.SSH_ASKPASS_REQUIRE, "force");
assert.ok(env.NETCATTY_FIDO_ASKPASS_SOCK);
assert.ok(fs.existsSync(env.SSH_ASKPASS));
const artifacts = ensureFidoAskpass();
assert.equal(artifacts.wrapperPath, env.SSH_ASKPASS);
shutdownFidoAskpass();
});
test("getTempBase uses Netcatty managed temp dir (no os.tmpdir fallback)", () => {
const tempDirBridge = require("./tempDirBridge.cjs");
const managed = tempDirBridge.getTempDir();
assert.equal(getTempBase(), managed);
assert.match(managed, /Netcatty/i);
});
test("classifyAskpassPrompt re-export works", () => {
assert.equal(classifyAskpassPrompt("Enter PIN for authenticator"), "pin");
assert.equal(classifyAskpassPrompt("Confirm user presence"), "touch");
});
test("releaseFidoAskpassLease cancels outstanding FIDO prompts for that lease", async () => {
const sent = [];
const sender = {
id: 77,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
const leaseId = "lease-release-cancel-test";
const pending = fidoPromptHandler.requestFidoPrompt(sender, {
kind: "pin",
message: "Enter PIN",
leaseId,
});
releaseFidoAskpassLease(leaseId);
assert.deepEqual(await pending, { cancelled: true });
assert.equal(fidoPromptHandler.getRequests().size, 0);
assert.ok(sent.some((entry) => entry.channel === "netcatty:fido-prompt-cancelled"));
});
@@ -0,0 +1,22 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const path = require("node:path");
const {
resolveFidoAskpassSocketPath,
isWindowsNamedPipePath,
} = require("./fidoAskpass.cjs");
test("resolveFidoAskpassSocketPath uses Windows named-pipe namespace on win32", () => {
const sock = resolveFidoAskpassSocketPath("C:\\Users\\netcatty\\Temp\\askpass-base", "win32");
assert.equal(isWindowsNamedPipePath(sock), true);
assert.match(sock, /^\\\\\.\\pipe\\netcatty-fido-askpass-/);
assert.equal(path.isAbsolute(sock), false);
});
test("resolveFidoAskpassSocketPath uses filesystem sockets on unix", () => {
const sock = resolveFidoAskpassSocketPath("/tmp/netcatty-fido-askpass-abcd", "linux");
assert.equal(sock, "/tmp/netcatty-fido-askpass-abcd/askpass.sock");
assert.equal(isWindowsNamedPipePath(sock), false);
});
+207
View File
@@ -0,0 +1,207 @@
"use strict";
/**
* FIDO2 PIN / touch prompts for OpenSSH sk-* flows.
* Mirrors passphraseHandler: main requests → renderer modal → respond IPC.
*/
const { randomUUID } = require("node:crypto");
/** @type {Map<string, {
* resolveCallback: (result: { cancelled?: boolean, response?: string|null }) => void,
* sender: Electron.WebContents,
* webContentsId: number,
* kind: string,
* leaseId: string,
* createdAt: number,
* timeoutId: NodeJS.Timeout,
* }>} */
const fidoPromptRequests = new Map();
const REQUEST_TTL_MS = 3 * 60 * 1000;
function generateRequestId(prefix = "fido") {
return `${prefix}-${randomUUID()}`;
}
function settleRequest(requestId, result, notification) {
const pending = fidoPromptRequests.get(requestId);
if (!pending) return false;
if (pending.timeoutId) clearTimeout(pending.timeoutId);
pending.abortCleanup?.();
fidoPromptRequests.delete(requestId);
if (notification) {
try {
if (!pending.sender?.isDestroyed?.()) {
pending.sender?.send?.(notification.channel, {
requestId,
...(notification.payload || {}),
});
}
} catch (err) {
console.warn(`[FidoPrompt] Failed to send ${notification.channel}:`, err.message);
}
}
pending.resolveCallback(result);
return true;
}
function cancelFidoPromptRequest(requestId, reason = "cancelled") {
return settleRequest(
requestId,
{ cancelled: true },
{
channel: "netcatty:fido-prompt-cancelled",
payload: { reason },
},
);
}
/**
* Cancel prompts owned by an askpass lease (ssh-add or inherited agent signing).
* @param {string} leaseId
* @param {string} [reason]
* @returns {number}
*/
function cancelFidoPromptRequestsForLease(leaseId, reason = "lease-released") {
if (typeof leaseId !== "string" || !leaseId) return 0;
let cancelled = 0;
for (const [requestId, pending] of [...fidoPromptRequests.entries()]) {
if (pending.leaseId !== leaseId) continue;
if (cancelFidoPromptRequest(requestId, reason)) cancelled += 1;
}
return cancelled;
}
/**
* @param {Electron.WebContents|null|undefined} sender
* @param {{
* kind: "pin" | "touch" | "confirm" | "recovery",
* reason?: string,
* allowPasswordFallback?: boolean,
* message?: string,
* title?: string,
* keyName?: string,
* notificationOnly?: boolean,
* leaseId?: string,
* signal?: AbortSignal,
* }} options
* @returns {Promise<{ cancelled?: boolean, response?: string|null } | null>}
*/
function requestFidoPrompt(sender, options = {}) {
return new Promise((resolve) => {
if (!sender || sender.isDestroyed?.()) {
console.warn("[FidoPrompt] No live sender; cannot show FIDO prompt");
resolve(null);
return;
}
const kind = ["touch", "confirm", "recovery"].includes(options.kind) ? options.kind : "pin";
const leaseId = typeof options.leaseId === "string" ? options.leaseId : "";
const requestId = generateRequestId(kind);
const timeoutId = setTimeout(() => {
settleRequest(
requestId,
{ cancelled: true },
{ channel: "netcatty:fido-prompt-timeout" },
);
}, REQUEST_TTL_MS);
fidoPromptRequests.set(requestId, {
resolveCallback: resolve,
sender,
webContentsId: sender.id,
kind,
leaseId,
createdAt: Date.now(),
timeoutId,
});
if (options.signal) {
const onAbort = () => cancelFidoPromptRequest(requestId, "helper-exited");
const pending = fidoPromptRequests.get(requestId);
pending.abortCleanup = () => options.signal.removeEventListener("abort", onAbort);
options.signal.addEventListener("abort", onAbort, { once: true });
if (options.signal.aborted) {
onAbort();
return;
}
}
try {
sender.send("netcatty:fido-prompt-request", {
requestId,
kind,
message: options.message || "",
title: options.title || "",
keyName: options.keyName || "",
notificationOnly: options.notificationOnly === true,
reason: options.reason,
allowPasswordFallback: options.allowPasswordFallback === true,
});
} catch (err) {
console.error("[FidoPrompt] Failed to send request:", err);
settleRequest(requestId, null);
}
});
}
function handleResponse(_event, payload = {}) {
const { requestId, response, cancelled } = payload;
const pending = fidoPromptRequests.get(requestId);
if (!pending) {
return { success: false, error: "Request not found" };
}
if (_event?.sender?.id !== pending.webContentsId) {
return { success: false, error: "Wrong sender" };
}
if (cancelled) {
settleRequest(requestId, { cancelled: true });
} else {
settleRequest(requestId, { response: typeof response === "string" ? response : "" });
}
return { success: true };
}
function registerHandler(ipcMain) {
ipcMain.handle("netcatty:fido-prompt:respond", handleResponse);
}
function getRequests() {
return fidoPromptRequests;
}
/**
* Classify OpenSSH / ssh-sk-helper askpass prompt text.
* @param {string} prompt
* @returns {"pin"|"touch"|"confirm"}
*/
function classifyAskpassPrompt(prompt) {
const p = String(prompt || "").toLowerCase();
// ssh-agent combines both requirements in one prompt for verify-required
// keys: "Enter PIN and confirm user presence ...". It still expects a PIN
// on stdout; classifying it as touch would silently return an empty string.
if (/\bpin\b|密码|密碼|口令/.test(p)) return "pin";
if (/confirm user presence|touch (your |the )?(security )?key|tap (your |the )?(security )?key|user presence|请触摸|請觸摸|请轻触/.test(p)) {
return "touch";
}
if (/pin|authenticator|fido|security key|enter.*code|密码|pin 码|口令/.test(p)) {
return "pin";
}
// Default to PIN-style secret entry for unknown askpass prompts from sk-helper.
return "pin";
}
module.exports = {
generateRequestId,
requestFidoPrompt,
cancelFidoPromptRequest,
cancelFidoPromptRequestsForLease,
handleResponse,
registerHandler,
getRequests,
classifyAskpassPrompt,
};
+106
View File
@@ -0,0 +1,106 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
classifyAskpassPrompt,
requestFidoPrompt,
cancelFidoPromptRequestsForLease,
handleResponse,
getRequests,
} = require("./fidoPromptHandler.cjs");
test("classifyAskpassPrompt maps OpenSSH sk-helper prompts", () => {
assert.equal(classifyAskpassPrompt("Enter PIN for authenticator:"), "pin");
assert.equal(classifyAskpassPrompt("Confirm user presence for key ED25519-SK"), "touch");
assert.equal(classifyAskpassPrompt("Touch your security key"), "touch");
assert.equal(classifyAskpassPrompt("请触摸你的安全密钥"), "touch");
assert.equal(classifyAskpassPrompt("Something unknown"), "pin");
});
test("requestFidoPrompt delivers IPC payload and resolves on respond", async () => {
const sent = [];
const sender = {
id: 42,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
const pending = requestFidoPrompt(sender, {
kind: "pin",
message: "Enter PIN for authenticator:",
keyName: "yubi",
});
assert.equal(sent.length, 1);
assert.equal(sent[0].channel, "netcatty:fido-prompt-request");
assert.equal(sent[0].payload.kind, "pin");
assert.equal(getRequests().size, 1);
const requestId = sent[0].payload.requestId;
const result = handleResponse(
{ sender: { id: 42 } },
{ requestId, response: "123456", cancelled: false },
);
assert.equal(result.success, true);
const resolved = await pending;
assert.deepEqual(resolved, { response: "123456" });
assert.equal(getRequests().size, 0);
});
test("requestFidoPrompt cancel path", async () => {
const sent = [];
const sender = {
id: 7,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
const pending = requestFidoPrompt(sender, { kind: "touch", message: "Touch key" });
const requestId = sent[0].payload.requestId;
handleResponse({ sender: { id: 7 } }, { requestId, cancelled: true });
const resolved = await pending;
assert.deepEqual(resolved, { cancelled: true });
});
test("cancelFidoPromptRequestsForLease settles owned prompts and notifies renderer", async () => {
const sent = [];
const sender = {
id: 11,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
const pending = requestFidoPrompt(sender, {
kind: "pin",
message: "Enter PIN",
leaseId: "lease-abc",
});
const other = requestFidoPrompt(sender, {
kind: "touch",
message: "Touch key",
leaseId: "lease-other",
});
const otherRequestId = sent.find((entry) => (
entry.channel === "netcatty:fido-prompt-request" && entry.payload.kind === "touch"
)).payload.requestId;
assert.equal(cancelFidoPromptRequestsForLease("lease-abc", "lease-released"), 1);
assert.deepEqual(await pending, { cancelled: true });
assert.equal(getRequests().size, 1);
assert.ok(sent.some((entry) => (
entry.channel === "netcatty:fido-prompt-cancelled"
&& entry.payload.reason === "lease-released"
)));
handleResponse({ sender: { id: 11 } }, { requestId: otherRequestId, cancelled: true });
await other;
});
test("combined OpenSSH PIN and user-presence prompts require secret entry", () => {
for (const algorithm of ["ED25519-SK", "ECDSA-SK"]) {
assert.equal(
classifyAskpassPrompt(`Enter PIN and confirm user presence for ${algorithm} key SHA256:test: `),
"pin",
);
}
});
+49
View File
@@ -0,0 +1,49 @@
"use strict";
const prompts = require("./fidoPromptHandler.cjs");
function failureReason(context) {
if (context.timedOut) return "timeout";
if (context.helperError === 60) return "device-unavailable";
if (context.helperError === 43 && context.pinSubmitted) return "pin-failed";
return "failed";
}
function cancelledError() {
const error = new Error("Security key authentication cancelled.");
error.code = "ERR_FIDO_CANCELLED";
return error;
}
async function signWithRecovery(context, options, sign) {
context.keyName = options.keyLabel || "";
context.allowPasswordFallback = Boolean(options.password);
for (;;) {
if (context.controller.signal.aborted) return { error: cancelledError() };
context.helperError = null;
context.pinSubmitted = false;
context.cancelled = false;
context.timedOut = false;
context.attemptController = new AbortController();
const result = await new Promise(resolve => {
try { sign((error, signature) => resolve({ error, signature })); }
catch (error) { resolve({ error }); }
});
context.attemptController.abort();
if (context.cancelled || context.controller.signal.aborted) return { error: cancelledError() };
if (!result.error && !context.timedOut) return result;
if (context.timedOut) result.error = new Error("Security key operation timed out");
const sender = options.resolveWebContents?.();
if (!sender || sender.isDestroyed?.()) return { error: cancelledError() };
const response = await prompts.requestFidoPrompt(sender, {
kind: "recovery", reason: failureReason(context), keyName: context.keyName,
allowPasswordFallback: context.allowPasswordFallback, leaseId: context.leaseId,
signal: context.controller.signal,
});
if (response?.response === "retry") continue;
if (response?.response === "fallback" && context.allowPasswordFallback) return result;
return { error: cancelledError() };
}
}
module.exports = { signWithRecovery, failureReason, cancelledError };
+41
View File
@@ -0,0 +1,41 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { signWithRecovery } = require("./fidoSignFlow.cjs");
const prompts = require("./fidoPromptHandler.cjs");
test("a signature arriving after cancellation is never returned to SSH", async () => {
const context = { leaseId: "cancelled-sign", controller: new AbortController() };
const result = await signWithRecovery(context, {}, callback => {
context.controller.abort();
callback(null, Buffer.from("late signature"));
});
assert.equal(result.error.code, "ERR_FIDO_CANCELLED");
assert.equal(result.signature, undefined);
});
test("a device result after the deadline requires recovery even if its signature is valid", async () => {
const context = { leaseId: "late-sign", controller: new AbortController() };
const received = [];
const sender = { id: 311, isDestroyed: () => false, send(channel, request) {
if (channel !== "netcatty:fido-prompt-request") return;
received.push(request);
queueMicrotask(() => prompts.handleResponse({ sender }, { requestId: request.requestId, cancelled: true }));
} };
const result = await signWithRecovery(context, { resolveWebContents: () => sender }, callback => {
context.timedOut = true;
callback(null, Buffer.from("late signature"));
});
assert.equal(received[0].reason, "timeout");
assert.equal(result.error.code, "ERR_FIDO_CANCELLED");
assert.equal(result.signature, undefined);
});
test("losing the prompt window fails closed instead of choosing a saved password", async () => {
const context = { leaseId: "missing-window", controller: new AbortController() };
const result = await signWithRecovery(context, { password: "test-only", resolveWebContents: () => null }, callback => {
callback(new Error("agent refused"));
});
assert.equal(result.error.code, "ERR_FIDO_CANCELLED");
});
+102
View File
@@ -0,0 +1,102 @@
"use strict";
// Observe the OpenSSH helper's error reply before ssh-agent reduces it to a
// generic SSH_AGENT_FAILURE. Requests/signatures are forwarded unchanged and
// are never logged. Windows agents without an executable wrapper keep their
// native helper; callers then use the generic recovery message.
const fs = require("node:fs");
const path = require("node:path");
const HELPER_SCRIPT = String.raw`"use strict";
const { spawn } = require("node:child_process");
const net = require("node:net");
const child = spawn(process.env.NETCATTY_FIDO_REAL_SK_HELPER, process.argv.slice(2), {
env: process.env, stdio: ["pipe", "pipe", "inherit"],
});
let control, response = [], size = 0, finished = false, exiting = false, exitCode = 1, connected = false;
const flush = () => {
if (exiting || !finished) return;
exiting = true;
control?.destroy();
process.stdout.end(Buffer.concat(response), () => process.exit(exitCode));
};
child.stdin.on("error", () => {});
process.stdin.pipe(child.stdin);
child.stdout.on("data", chunk => {
size += chunk.length;
if (size > 1024 * 1024) { child.kill(); return; }
response.push(chunk);
});
let incoming = "";
let errorCode = null;
const report = () => {
if (!finished) return;
if (connected && control?.writable && !control.destroyed) {
control.write(JSON.stringify({ phase: "result", errorCode }) + "\n");
} else if (!control || control.destroyed) flush();
};
try {
control = net.createConnection(process.env.NETCATTY_FIDO_ASKPASS_SOCK);
control.on("connect", () => {
connected = true;
control.write(JSON.stringify({ type: "sk-helper" }) + "\n");
report();
});
control.setEncoding("utf8");
control.on("data", chunk => {
incoming += chunk;
let nl;
while ((nl = incoming.indexOf("\n")) !== -1) {
const line = incoming.slice(0, nl); incoming = incoming.slice(nl + 1);
let message; try { message = JSON.parse(line); } catch { continue; }
if (message.cancel === true) child.kill("SIGTERM");
if (message.ack === true) flush();
}
});
control.on("error", flush);
control.on("close", flush);
} catch { /* The native helper still works if observation is unavailable. */ }
const complete = code => {
if (finished) return;
finished = true; exitCode = code === 0 ? 0 : 1;
const packet = Buffer.concat(response);
// ssh_msg framing: length, version, reply type, positive SSH error number.
errorCode = packet.length === 13 && packet[4] === 5 && packet.readUInt32BE(0) === 9
&& packet.readUInt32BE(5) === 0 ? packet.readUInt32BE(9) : null;
report();
setTimeout(flush, 1000).unref();
};
child.on("error", () => complete(1));
child.on("close", complete);
process.on("SIGTERM", () => { child.kill("SIGTERM"); complete(1); });
`;
function resolveSkHelper(sshAgentPath, env = process.env) {
const agents = [sshAgentPath];
try { agents.push(fs.realpathSync(sshAgentPath)); } catch { /* PATH binary */ }
const candidates = [env.SSH_SK_HELPER];
for (const agent of agents.filter(Boolean)) {
candidates.push(path.resolve(path.dirname(agent), "../libexec/ssh-sk-helper"));
candidates.push(path.resolve(path.dirname(agent), "../libexec/ssh/ssh-sk-helper"));
}
candidates.push("/usr/lib/openssh/ssh-sk-helper", "/usr/libexec/ssh-sk-helper", "/usr/lib/ssh/ssh-sk-helper");
return candidates.find(candidate => {
if (!candidate) return false;
try { fs.accessSync(candidate, fs.constants.X_OK); return true; } catch { return false; }
}) || null;
}
function buildSkHelperEnv(sshAgentPath, askpassEnv, env = process.env, platform = process.platform) {
if (platform === "win32") return {};
const helper = resolveSkHelper(sshAgentPath, env);
if (!helper || !askpassEnv?.SSH_ASKPASS) return {};
const base = path.dirname(askpassEnv.SSH_ASKPASS);
const script = path.join(base, "sk-helper.cjs");
const wrapper = path.join(base, "sk-helper.sh");
fs.writeFileSync(script, HELPER_SCRIPT, { mode: 0o700 });
const quote = value => "'" + value.replace(/'/g, "'\\''") + "'";
fs.writeFileSync(wrapper, `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${quote(process.execPath)} ${quote(script)} "$@"\n`, { mode: 0o700 });
return { SSH_SK_HELPER: wrapper, NETCATTY_FIDO_REAL_SK_HELPER: helper };
}
module.exports = { buildSkHelperEnv, resolveSkHelper };
+150
View File
@@ -0,0 +1,150 @@
"use strict";
/**
* Generate OpenSSH FIDO2 (sk-*) key pairs via ssh-keygen.
*
* Requires a platform OpenSSH build with libfido2 (e.g. Homebrew openssh on
* macOS). The user must touch the security key (and enter a PIN when required).
*/
const fs = require("node:fs");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const { randomUUID } = require("node:crypto");
const execFileAsync = promisify(execFile);
function resolveSshKeygenBinary(env = process.env) {
if (typeof env.NETCATTY_SSH_KEYGEN_PATH === "string" && env.NETCATTY_SSH_KEYGEN_PATH.trim()) {
return env.NETCATTY_SSH_KEYGEN_PATH.trim();
}
if (process.platform === "darwin") {
// Prefer Homebrew OpenSSH when present (system ssh often lacks libfido2).
for (const candidate of [
"/opt/homebrew/bin/ssh-keygen",
"/usr/local/bin/ssh-keygen",
"/usr/bin/ssh-keygen",
]) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// continue
}
}
}
return "ssh-keygen";
}
/**
* @param {{
* type: "ED25519-SK" | "ECDSA-SK",
* comment?: string,
* resident?: boolean,
* verifyRequired?: boolean,
* env?: NodeJS.ProcessEnv,
* execFile?: typeof execFileAsync,
* sshKeygenPath?: string,
* }} options
*/
async function generateFidoSshKeyPair(options = {}) {
const type = options.type === "ECDSA-SK" ? "ecdsa-sk" : "ed25519-sk";
const comment = options.comment || "netcatty-fido-key";
const env = options.env || process.env;
const run = options.execFile || execFileAsync;
const sshKeygen = options.sshKeygenPath || resolveSshKeygenBinary(env);
let baseDir;
try {
const tempDirBridge = require("./tempDirBridge.cjs");
if (typeof tempDirBridge.getTempDir !== "function") {
return {
success: false,
error: "FIDO2 key generation requires Netcatty temp directory (tempDirBridge unavailable).",
};
}
baseDir = tempDirBridge.getTempDir();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `FIDO2 key generation requires Netcatty temp directory: ${message}`,
};
}
const tempDir = await fs.promises.mkdtemp(path.join(baseDir, "netcatty-fido-keygen-"));
const keyPath = path.join(tempDir, `id_${type.replace(/-sk$/, "_sk")}_${randomUUID().slice(0, 8)}`);
let askpassLeaseId = null;
try {
const args = [
"-t", type,
"-f", keyPath,
"-C", comment,
"-N", "", // empty passphrase on the key *handle* file (PIN is on the device)
];
if (options.resident) args.push("-O", "resident");
if (options.verifyRequired) args.push("-O", "verify-required");
let askpassEnv = {};
try {
const { buildFidoAskpassEnv } = require("./fidoAskpass.cjs");
askpassEnv = buildFidoAskpassEnv({
resolveWebContents: options.resolveWebContents,
});
askpassLeaseId = askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE || null;
} catch {
// GUI askpass unavailable in pure unit tests
}
try {
await run(sshKeygen, args, {
timeout: 180000,
windowsHide: true,
env: {
...env,
...askpassEnv,
// Force askpass so PIN/touch go through Netcatty modals (no TTY).
SSH_ASKPASS_REQUIRE: askpassEnv.SSH_ASKPASS
? "force"
: (env.SSH_ASKPASS_REQUIRE || undefined),
},
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
const stderr = error?.stderr?.toString?.() || error?.message || String(error);
const hint = /provider|fido|sk_|feature not supported|invalid format|device not found|no such file/i.test(stderr)
? " Ensure OpenSSH is built with libfido2 (macOS: brew install openssh libfido2) and a FIDO2 key is plugged in."
: "";
return {
success: false,
error: `FIDO2 key generation failed: ${stderr.trim()}.${hint}`,
};
}
const [privateKey, publicKey] = await Promise.all([
fs.promises.readFile(keyPath, "utf8"),
fs.promises.readFile(`${keyPath}.pub`, "utf8"),
]);
return {
success: true,
privateKey,
publicKey: publicKey.trim(),
keyType: options.type === "ECDSA-SK" ? "ECDSA-SK" : "ED25519-SK",
};
} finally {
if (askpassLeaseId) {
try {
require("./fidoAskpass.cjs").releaseFidoAskpassLease(askpassLeaseId);
} catch {
// ignore
}
}
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
module.exports = {
generateFidoSshKeyPair,
resolveSshKeygenBinary,
};
+35 -5
View File
@@ -1405,13 +1405,19 @@ async function startPortForward(event, payload) {
let defaultKeys = [];
let portForwardAuthPhase = { hadPartialSuccess: false, passwordAlreadySucceeded: false };
let authBanner = "";
/** @type {(() => void)|null} */
let releasePreparedFidoAgent = null;
try {
const fallbackAgentSocket = useSshAgent === false
? null
: useSshAgent === true
? undefined
: await getAvailableAgentSocket(identityAgent, { hostname, port, username });
const systemAuthAgent = hasCertificate ? null : await prepareSystemSshAgentForAuth({
const {
enhanceAuthOptionsForFido,
looksLikeSkOpenSshMaterial,
} = require("./sshAuthHelper.cjs");
const prep = await enhanceAuthOptionsForFido({
useSshAgent,
agentPublicKeys,
identityAgent,
@@ -1419,10 +1425,24 @@ async function startPortForward(event, payload) {
identitiesOnly,
addKeysToAgent,
useKeychain,
privateKey,
certificate,
hostname,
port,
username,
}, "[PortForward]");
}, sender);
const isFido = prep.useFidoAgent === true;
const systemAuthAgent = (hasCertificate && !isFido) ? null : await prepareSystemSshAgentForAuth(
prep,
"[PortForward]",
);
// Attach immediately so cancelTunnel ending `conn` during/after prepare
// still drops the owned FIDO agent + askpass lease. Also keep a direct
// handle for cancel-before-connect returns (close may have fired before
// this listener existed).
releasePreparedFidoAgent = systemAuthAgent
? require("./attachFidoAgentRelease.cjs").attachFidoAgentRelease(conn, systemAuthAgent)
: null;
const identityFile = !privateKey && !systemAuthAgent
? await loadFirstIdentityFileForAuth({
sender,
@@ -1436,7 +1456,9 @@ async function startPortForward(event, payload) {
},
})
: null;
const inlineKey = privateKey && !systemAuthAgent
const inlineKey = privateKey
&& !systemAuthAgent
&& !looksLikeSkOpenSshMaterial(privateKey)
? await preparePrivateKeyForAuth({
sender,
privateKey,
@@ -1452,6 +1474,7 @@ async function startPortForward(event, payload) {
const effectivePassphrase = inlineKey?.passphrase || identityFile?.passphrase;
if (isTunnelCancelled(tunnelState)) {
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
portForwardingTunnels.delete(tunnelId);
abandonPendingDial("Port forward connection cancelled");
return { tunnelId, success: false, cancelled: true };
@@ -1460,7 +1483,7 @@ async function startPortForward(event, payload) {
if (systemAuthAgent) {
connectOpts.agent = systemAuthAgent;
}
if (hasCertificate) {
if (hasCertificate && !isFido) {
connectOpts.agent = new NetcattyAgent({
mode: "certificate",
webContents: sender,
@@ -1471,7 +1494,7 @@ async function startPortForward(event, payload) {
passphrase: effectivePassphrase,
},
});
} else if (effectivePrivateKey) {
} else if (effectivePrivateKey && !isFido) {
connectOpts.privateKey = effectivePrivateKey;
if (effectivePassphrase) {
connectOpts.passphrase = effectivePassphrase;
@@ -1488,6 +1511,7 @@ async function startPortForward(event, payload) {
? []
: discoveredDefaultKeys;
if (isTunnelCancelled(tunnelState)) {
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
portForwardingTunnels.delete(tunnelId);
abandonPendingDial("Port forward connection cancelled");
return { tunnelId, success: false, cancelled: true };
@@ -1510,6 +1534,7 @@ async function startPortForward(event, payload) {
applyAuthToConnOpts(connectOpts, authConfig);
portForwardAuthPhase = authConfig.authPhase || portForwardAuthPhase;
if (isTunnelCancelled(tunnelState)) {
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
portForwardingTunnels.delete(tunnelId);
abandonPendingDial("Port forward connection cancelled");
return { tunnelId, success: false, cancelled: true };
@@ -1556,6 +1581,7 @@ async function startPortForward(event, payload) {
chainConnections = chainResult.connections;
tunnelState.chainConnections = chainConnections;
if (isTunnelCancelled(tunnelState)) {
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
cleanupChainConnections(chainConnections);
if (!tunnelState.cleanupFailed) {
portForwardingTunnels.delete(tunnelId);
@@ -1574,6 +1600,7 @@ async function startPortForward(event, payload) {
},
});
if (isTunnelCancelled(tunnelState)) {
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
try { connectionSocket?.end?.(); } catch { /* ignore */ }
try { connectionSocket?.destroy?.(); } catch { /* ignore */ }
if (!tunnelState.cleanupFailed) {
@@ -1589,6 +1616,7 @@ async function startPortForward(event, payload) {
}
} catch (err) {
if (isTunnelCancelled(tunnelState)) {
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
if (!tunnelState.cleanupFailed) {
portForwardingTunnels.delete(tunnelId);
}
@@ -1601,10 +1629,12 @@ async function startPortForward(event, payload) {
} catch {
/* best-effort cancel on passphrase cancel */
}
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
abandonPendingDial(err);
return { tunnelId, success: false, cancelled: true };
}
tunnelState.cancelled = true;
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
if (tunnelState.pendingConn) {
try { tunnelState.pendingConn.end(); } catch { /* ignore */ }
}
+32 -11
View File
@@ -245,9 +245,15 @@ function createOpenConnectionApi(ctx) {
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
const systemAuthAgent = hasCertificate
const { enhanceAuthOptionsForFido, looksLikeSkOpenSshMaterial } = require("../sshAuthHelper.cjs");
const prep = await enhanceAuthOptionsForFido(jump, sender);
const isFido = prep.useFidoAgent === true;
const systemAuthAgent = (hasCertificate && !isFido)
? null
: await prepareSystemSshAgentForAuth(jump, `[SFTP Chain] Hop ${i + 1}:`);
: await prepareSystemSshAgentForAuth(
prep,
`[SFTP Chain] Hop ${i + 1}:`,
);
const identityFile = !jump.privateKey && !systemAuthAgent
? await loadFirstIdentityFileForAuth({
@@ -264,7 +270,9 @@ function createOpenConnectionApi(ctx) {
},
})
: null;
const inlineKey = jump.privateKey && !systemAuthAgent
const inlineKey = jump.privateKey
&& !systemAuthAgent
&& !looksLikeSkOpenSshMaterial(jump.privateKey)
? await preparePrivateKeyForAuth({
sender,
privateKey: jump.privateKey,
@@ -281,8 +289,9 @@ function createOpenConnectionApi(ctx) {
let authAgent = null;
if (systemAuthAgent) {
connOpts.agent = systemAuthAgent;
require("../attachFidoAgentRelease.cjs").attachFidoAgentRelease(conn, systemAuthAgent);
}
if (hasCertificate) {
if (hasCertificate && !isFido) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
@@ -294,7 +303,7 @@ function createOpenConnectionApi(ctx) {
},
});
connOpts.agent = authAgent;
} else if (effectivePrivateKey) {
} else if (effectivePrivateKey && !isFido) {
connOpts.privateKey = effectivePrivateKey;
if (effectivePassphrase) {
connOpts.passphrase = effectivePassphrase;
@@ -999,10 +1008,17 @@ function createOpenConnectionApi(ctx) {
let identityFile = null;
let inlineKey = null;
let systemAuthAgent = null;
let isFido = false;
try {
systemAuthAgent = hasCertificate
const { enhanceAuthOptionsForFido, looksLikeSkOpenSshMaterial } = require("../sshAuthHelper.cjs");
const prep = await enhanceAuthOptionsForFido(options, event.sender);
isFido = prep.useFidoAgent === true;
systemAuthAgent = (hasCertificate && !isFido)
? null
: await prepareSystemSshAgentForAuth(options, "[SFTP]");
: await prepareSystemSshAgentForAuth(
prep,
"[SFTP]",
);
identityFile = !options.privateKey && !systemAuthAgent
? await loadFirstIdentityFileForAuth({
sender: event.sender,
@@ -1018,7 +1034,9 @@ function createOpenConnectionApi(ctx) {
},
})
: null;
inlineKey = options.privateKey && !systemAuthAgent
inlineKey = options.privateKey
&& !systemAuthAgent
&& !looksLikeSkOpenSshMaterial(options.privateKey)
? await preparePrivateKeyForAuth({
sender: event.sender,
privateKey: options.privateKey,
@@ -1040,7 +1058,7 @@ function createOpenConnectionApi(ctx) {
if (systemAuthAgent) {
connectOpts.agent = systemAuthAgent;
}
if (hasCertificate) {
if (hasCertificate && !isFido) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
@@ -1052,7 +1070,7 @@ function createOpenConnectionApi(ctx) {
},
});
connectOpts.agent = authAgent;
} else if (effectivePrivateKey) {
} else if (effectivePrivateKey && !isFido) {
connectOpts.privateKey = effectivePrivateKey;
if (effectivePassphrase) {
connectOpts.passphrase = effectivePassphrase;
@@ -1150,7 +1168,10 @@ function createOpenConnectionApi(ctx) {
// By connecting directly, we can filter these non-fatal errors and allow
// the auth flow to continue to keyboard-interactive/password/etc.
const sshClient = client.client;
if (systemAuthAgent) {
require("../attachFidoAgentRelease.cjs").attachFidoAgentRelease(sshClient, systemAuthAgent);
}
await new Promise((resolve, reject) => {
let settled = false;
let authReadyTimer = null;
+148
View File
@@ -0,0 +1,148 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
parseKey,
getOpenSSHCertSignatureAlgorithm,
} = require("ssh2/lib/protocol/keyParser.js");
function buildSkEd25519PublicLine() {
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
const pub = Buffer.alloc(32, 9);
const app = Buffer.from("ssh:");
const parts = [type, pub, app];
let len = 0;
for (const part of parts) len += 4 + part.length;
const buf = Buffer.alloc(len);
let offset = 0;
for (const part of parts) {
buf.writeUInt32BE(part.length, offset);
offset += 4;
part.copy(buf, offset);
offset += part.length;
}
return `sk-ssh-ed25519@openssh.com ${buf.toString("base64")} netcatty@sk`;
}
function buildSkEcdsaPublicLine() {
// Uncompressed P-256 point: 0x04 || X(32) || Y(32)
const point = Buffer.alloc(65, 0);
point[0] = 0x04;
point[64] = 1;
const type = Buffer.from("sk-ecdsa-sha2-nistp256@openssh.com");
const curve = Buffer.from("nistp256");
const app = Buffer.from("ssh:");
const parts = [type, curve, point, app];
let len = 0;
for (const part of parts) len += 4 + part.length;
const buf = Buffer.alloc(len);
let offset = 0;
for (const part of parts) {
buf.writeUInt32BE(part.length, offset);
offset += 4;
part.copy(buf, offset);
offset += part.length;
}
return `sk-ecdsa-sha2-nistp256@openssh.com ${buf.toString("base64")} netcatty@sk`;
}
test("ssh2 parseKey accepts sk-ssh-ed25519 public keys", () => {
const key = parseKey(buildSkEd25519PublicLine());
assert.equal(key instanceof Error, false, key instanceof Error ? key.message : "");
assert.equal(key.type, "sk-ssh-ed25519@openssh.com");
assert.equal(typeof key.getPublicSSH, "function");
assert.equal(key.getApplication?.(), "ssh:");
const signErr = key.sign(Buffer.from("challenge"));
assert.equal(signErr instanceof Error, true);
assert.match(String(signErr.message), /security-key|ssh-agent|FIDO/i);
});
test("ssh2 parseKey accepts sk-ecdsa-sha2-nistp256 public keys", () => {
const key = parseKey(buildSkEcdsaPublicLine());
assert.equal(key instanceof Error, false, key instanceof Error ? key.message : "");
assert.equal(key.type, "sk-ecdsa-sha2-nistp256@openssh.com");
assert.equal(key.getApplication?.(), "ssh:");
});
function buildSkEd25519CertPublicLine() {
// Minimal OpenSSH SK cert wire shape: type, nonce, then SK public fields.
const type = Buffer.from("sk-ssh-ed25519-cert-v01@openssh.com");
const nonce = Buffer.alloc(16, 7);
const pub = Buffer.alloc(32, 9);
const app = Buffer.from("ssh:");
const parts = [type, nonce, pub, app];
let len = 0;
for (const part of parts) len += 4 + part.length;
const buf = Buffer.alloc(len);
let offset = 0;
for (const part of parts) {
buf.writeUInt32BE(part.length, offset);
offset += 4;
part.copy(buf, offset);
offset += part.length;
}
return `sk-ssh-ed25519-cert-v01@openssh.com ${buf.toString("base64")} netcatty@sk-cert`;
}
test("ssh2 parseKey accepts OpenSSH-order SK certificate public keys", () => {
const line = buildSkEd25519CertPublicLine();
const certBlob = Buffer.from(line.split(/\s+/)[1], "base64");
const key = parseKey(line);
assert.equal(key instanceof Error, false, key instanceof Error ? key.message : "");
assert.equal(key.type, "sk-ssh-ed25519-cert-v01@openssh.com");
assert.equal(key.getApplication?.(), "ssh:");
assert.deepEqual(key.getPublicSSH(), certBlob);
});
test("ssh2 maps SK certificate identities to full wire signature algorithms", () => {
assert.equal(
getOpenSSHCertSignatureAlgorithm("sk-ssh-ed25519-cert-v01@openssh.com"),
"sk-ssh-ed25519@openssh.com",
);
assert.equal(
getOpenSSHCertSignatureAlgorithm("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com"),
"sk-ecdsa-sha2-nistp256@openssh.com",
);
assert.equal(
getOpenSSHCertSignatureAlgorithm("rsa-sha2-256-cert-v01@openssh.com"),
"rsa-sha2-256",
);
assert.equal(
getOpenSSHCertSignatureAlgorithm("ecdsa-sha2-nistp256-cert-v01@openssh.com"),
"ecdsa-sha2-nistp256",
);
assert.equal(
getOpenSSHCertSignatureAlgorithm("sk-ssh-ed25519@openssh.com"),
"sk-ssh-ed25519@openssh.com",
);
});
test("ssh2 agent protocol preserves SK signature framing, flags and counter", async () => {
const { AgentProtocol } = require("ssh2/lib/agent.js");
for (const line of [buildSkEd25519PublicLine(), buildSkEcdsaPublicLine(), buildSkEd25519CertPublicLine()]) {
const client = new AgentProtocol(true);
const server = new AgentProtocol(false);
client.pipe(server).pipe(client);
const raw = Buffer.alloc(64, 7);
const payload = Buffer.alloc(4 + raw.length + 5);
payload.writeUInt32BE(raw.length, 0);
raw.copy(payload, 4);
payload[4 + raw.length] = 5; // presence and verification
payload.writeUInt32BE(0x12345678, 5 + raw.length);
server.on("sign", (request, key) => {
assert.equal(request.getContext(), getOpenSSHCertSignatureAlgorithm(key.type));
server.signReply(request, payload);
});
try {
const returned = await new Promise((resolve, reject) => client.sign(
parseKey(line), Buffer.from("challenge"), {},
(error, signature) => error ? reject(error) : resolve(signature),
));
assert.deepEqual(returned, payload);
} finally {
client.destroy();
server.destroy();
}
}
});
+552 -27
View File
@@ -7,9 +7,10 @@ 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 childProcess = require("node:child_process");
const { exec } = childProcess;
const { utils: sshUtils } = require("ssh2");
const { prepareSystemSshAgent } = require("./systemSshAgent.cjs");
const systemSshAgent = require("./systemSshAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const {
@@ -142,10 +143,8 @@ function isKeyEncrypted(keyContent) {
return false;
}
function expandIdentityFilePath(keyPath) {
return keyPath.startsWith("~/")
? path.join(os.homedir(), keyPath.slice(2))
: keyPath;
function expandIdentityFilePath(keyPath, context = {}) {
return systemSshAgent.resolveIdentityPath(keyPath, context);
}
function notifyPassphraseAuthFailed(sender, keyPath, resolvedPath, keyIds) {
@@ -900,31 +899,548 @@ async function getNativeOpenSshForwardingAgentSocket(identityAgent, injected = {
return socketPath;
}
const OPENSSH_PRIVATE_KEY_RE =
/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END OPENSSH PRIVATE KEY-----/;
const SK_SSH_ED25519 = "sk-ssh-ed25519@openssh.com";
const SK_ECDSA_NISTP256 = "sk-ecdsa-sha2-nistp256@openssh.com";
/**
* Detect OpenSSH FIDO sk-* material in public keys *or* private-key PEMs.
*
* Real sk private PEMs only embed `sk-*-@openssh.com` inside the base64 body
* (`@` is not in the base64 alphabet), so a raw-text regex on the PEM fails.
* Mirror domain/fidoSsh.ts isSkPrivateKey: decode the PEM body first.
*/
function looksLikeSkOpenSshMaterial(text) {
if (typeof text !== "string" || !text.trim()) return false;
// Public keys / agent blobs / already-decoded comments keep the type in plain text.
// Include sk-*-cert-v01@openssh.com certificate algorithms.
if (/sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)(?:-cert-v0[01])?@openssh\.com/.test(text)) return true;
const match = OPENSSH_PRIVATE_KEY_RE.exec(text);
if (!match) return false;
try {
const body = Buffer.from(match[1].replace(/\s+/g, ""), "base64").toString("binary");
return body.includes(SK_SSH_ED25519) || body.includes(SK_ECDSA_NISTP256)
|| /sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)(?:-cert-v0[01])?@openssh\.com/.test(body);
} catch {
return false;
}
}
/**
* Peek IdentityFile paths for sk-* public/private material so path-only
* reference keys still force the owned FIDO agent + askpass path.
* @param {string[]} identityFilePaths
* @param {{ readFile?: typeof fs.promises.readFile, expandIdentityFilePath?: Function }} [injected]
*/
async function identityFilesLookLikeSk(identityFilePaths, injected = {}) {
if (!Array.isArray(identityFilePaths) || identityFilePaths.length === 0) return false;
const readFile = injected.readFile || fs.promises.readFile;
const expand = injected.expandIdentityFilePath || expandIdentityFilePath;
for (const rawPath of identityFilePaths) {
const identityPath = expand(rawPath, injected);
if (!identityPath) continue;
const candidates = identityPath.endsWith(".pub")
? [identityPath]
: [`${identityPath}.pub`, identityPath];
for (const candidate of candidates) {
try {
const contents = await readFile(candidate, "utf8");
if (looksLikeSkOpenSshMaterial(contents)) return true;
} catch {
// try next candidate
}
}
}
return false;
}
function resolveWebContentsFromSender(sender) {
return () => {
try {
return sender && !sender.isDestroyed?.() ? sender : null;
} catch {
return null;
}
};
}
/** True when options carry FIDO2 sk-* material that needs agent + askpass. */
function isFidoSkAuthOptions(options) {
if (!options || typeof options !== "object") return false;
if (options.useFidoAgent === true) return true;
if (looksLikeSkOpenSshMaterial(options.privateKey)) return true;
if (Array.isArray(options.agentPublicKeys)
&& options.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key))) {
return true;
}
return false;
}
/**
* Normalize auth options for prepareSystemSshAgentForAuth so every surface
* (SSH / SFTP / Mosh / ET / port-forward) gets askpass + FIDO agent force.
* Sync: only inspects inline material. Prefer enhanceAuthOptionsForFido when
* IdentityFile paths may hold sk-* handles without vault type markers.
* @param {object} options
* @param {Electron.WebContents} [sender]
*/
function buildFidoAwareAgentPrepOptions(options = {}, sender) {
const isFido = isFidoSkAuthOptions(options) && options.authMethod !== "password";
return {
...options,
useSshAgent: isFido ? true : options.useSshAgent,
useFidoAgent: isFido || options.useFidoAgent === true,
loadIdentityFilesIntoAgent: isFido || options.loadIdentityFilesIntoAgent === true,
addKeysToAgent: options.addKeysToAgent || (isFido ? "yes" : options.addKeysToAgent),
resolveWebContents: options.resolveWebContents || (sender
? resolveWebContentsFromSender(sender)
: options.resolveWebContents),
};
}
/**
* Async FIDO prep: also peeks IdentityFile paths so path-only SK keys force
* the owned agent on every surface (not only startSession).
*/
async function enhanceAuthOptionsForFido(options = {}, sender) {
const base = buildFidoAwareAgentPrepOptions(options, sender);
if (base.useFidoAgent === true || base.authMethod === "password") return base;
if (!Array.isArray(options.identityFilePaths) || options.identityFilePaths.length === 0) {
return base;
}
try {
if (await identityFilesLookLikeSk(options.identityFilePaths, options)) {
return {
...base,
useSshAgent: true,
useFidoAgent: true,
loadIdentityFilesIntoAgent: true,
addKeysToAgent: options.addKeysToAgent || "yes",
};
}
} catch {
// ignore probe failures
}
return base;
}
/**
* True when certificate auth must stay on the software NetcattyAgent path.
* FIDO SK + certificate must keep the system/owned agent (hardware signs).
*/
function shouldUseSoftwareCertificateAgent(options = {}, isFidoSk = false) {
const hasCertificate = typeof options.certificate === "string"
&& options.certificate.trim().length > 0;
return hasCertificate && !isFidoSk;
}
/**
* After prepareSystemSshAgentForAuth, resolve the socket path for native ssh
* (Mosh/ET IdentityAgent / SSH_AUTH_SOCK). Prefers the owned FIDO agent.
*/
function resolvePreparedAgentSocket(agent, options = {}) {
if (agent && typeof agent._netcattyAgentSocket === "string" && agent._netcattyAgentSocket) {
return agent._netcattyAgentSocket;
}
try {
const { getActiveFidoAgentSocket } = require("./fidoAgentManager.cjs");
const owned = getActiveFidoAgentSocket();
if (owned) return owned;
} catch {
// ignore
}
return null;
}
/**
* Persist an inline FIDO2 sk-* private key handle so ssh-add can load it.
* Returns the temp path (or null). Caller should clean up when done if needed;
* agent retains the identity after add.
*/
function deriveSkPublicKeyLine(privateKey) {
try {
const parsed = sshUtils.parseKey(privateKey);
if (parsed instanceof Error || typeof parsed?.getPublicSSH !== "function") return null;
const type = typeof parsed.type === "string" ? parsed.type : "";
const blob = parsed.getPublicSSH();
if (!type || !blob) return null;
return `${type} ${Buffer.from(blob).toString("base64")} netcatty-sk`;
} catch {
return null;
}
}
async function materializeSkPrivateKeyFile(privateKey, injected = {}) {
if (!looksLikeSkOpenSshMaterial(privateKey)) return null;
const fsMod = injected.fs || fs;
const pathMod = injected.path || path;
const tempDirBridge = injected.tempDirBridge || require("./tempDirBridge.cjs");
// Fail closed: never stage SK credential handles under the OS temp directory.
if (typeof tempDirBridge.getTempDir !== "function") {
const err = new Error(
"FIDO2 key staging requires Netcatty temp directory (tempDirBridge unavailable).",
);
err.code = "ERR_FIDO_TEMP_DIR_UNAVAILABLE";
throw err;
}
const baseDir = tempDirBridge.getTempDir();
const dir = await fsMod.promises.mkdtemp(pathMod.join(baseDir, "netcatty-sk-key-"));
const keyPath = pathMod.join(dir, "id_sk");
await fsMod.promises.writeFile(keyPath, privateKey, { mode: 0o600 });
// Companion .pub lets shared-agent prep recognize an already-loaded identity
// across unique per-session temp paths (refcount cleanup keys on the blob).
const publicKeyHint = typeof injected.publicKey === "string" ? injected.publicKey.trim() : "";
const publicKeyLine = publicKeyHint || deriveSkPublicKeyLine(privateKey);
if (publicKeyLine) {
await fsMod.promises.writeFile(`${keyPath}.pub`, `${publicKeyLine}\n`, { mode: 0o600 });
}
const certificate = typeof injected.certificate === "string" ? injected.certificate.trim() : "";
if (certificate) {
// OpenSSH ssh-add auto-loads `<key>-cert.pub` next to the private handle.
await fsMod.promises.writeFile(`${keyPath}-cert.pub`, `${certificate}\n`, { mode: 0o600 });
}
return { keyPath, cleanupDir: dir };
}
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,
});
let pathBackedSk = false;
// Password-only auth must never probe IdentityFiles for SK material — that
// would spawn an owned agent and advertise publickey against the user's
// explicit password choice.
if (options.authMethod !== "password"
&& !options.useFidoAgent
&& !looksLikeSkOpenSshMaterial(options.privateKey)
&& !(Array.isArray(options.agentPublicKeys)
&& options.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key)))
&& Array.isArray(options.identityFilePaths)
&& options.identityFilePaths.length > 0) {
try {
pathBackedSk = await identityFilesLookLikeSk(options.identityFilePaths, options);
} catch {
pathBackedSk = false;
}
}
const isFidoFlow = looksLikeSkOpenSshMaterial(options.privateKey)
|| (Array.isArray(options.agentPublicKeys)
&& options.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key)))
|| options.useFidoAgent === true
|| options.loadIdentityFilesIntoAgent === true
|| pathBackedSk;
// Path-backed / flagged FIDO keys imply agent auth even when the caller did
// not already set useSshAgent (vault default for soft keys is often false).
if (options?.useSshAgent !== true && !isFidoFlow) return null;
let socketPath = null;
let askpassEnv = null;
let ownedFidoAgent = false;
/** @type {number|undefined} */
let ownedFidoAgentGeneration;
/** @type {string|null} */
let fidoSshAddPath = null;
/** True when acquireFidoAgent succeeded (owned or shared Windows pipe). */
let acquiredFidoAgent = false;
if (isFidoFlow) {
try {
const { acquireFidoAgent } = require("./fidoAgentManager.cjs");
// acquireFidoAgent allocates the askpass lease for this caller — do not
// build a separate lease here or it would leak when overwritten.
const fidoAgent = await acquireFidoAgent({
resolveWebContents: options.resolveWebContents,
env: process.env,
});
socketPath = fidoAgent.socketPath;
askpassEnv = fidoAgent.askpassEnv || null;
ownedFidoAgent = fidoAgent.owned === true;
ownedFidoAgentGeneration = fidoAgent.generation;
fidoSshAddPath = typeof fidoAgent.sshAddPath === "string" ? fidoAgent.sshAddPath : null;
acquiredFidoAgent = true;
console.log(`${logPrefix} Using Netcatty FIDO ssh-agent`, {
socketPath,
owned: ownedFidoAgent,
});
} catch (error) {
console.log(`${logPrefix} FIDO agent start failed; falling back to system agent`, {
error: error instanceof Error ? error.message : String(error),
});
}
}
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";
socketPath = await getAvailableAgentSocket(options.identityAgent, {
hostname: options.hostname,
port: options.port,
username: options.username,
});
}
if (!socketPath) {
const error = new Error(
isFidoFlow
? "FIDO2 SSH agent is unavailable. Install OpenSSH with libfido2 (macOS: brew install openssh libfido2) and try again."
: "System SSH agent is unavailable. Start or unlock it, or configure a valid agent socket.",
);
error.code = isFidoFlow ? "ERR_FIDO_AGENT_UNAVAILABLE" : "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 ?? ""),
});
if (isFidoFlow && !askpassEnv) {
try {
const { buildFidoAskpassEnv } = require("./fidoAskpass.cjs");
askpassEnv = buildFidoAskpassEnv({
resolveWebContents: options.resolveWebContents,
});
} catch {
askpassEnv = null;
}
}
const identityFilePaths = Array.isArray(options.identityFilePaths)
? [...options.identityFilePaths]
: [];
const publicKeyHint = Array.isArray(options.agentPublicKeys)
? options.agentPublicKeys.find((key) => looksLikeSkOpenSshMaterial(key))
: null;
let skTempCleanup = null;
if (looksLikeSkOpenSshMaterial(options.privateKey)) {
try {
const materialized = await materializeSkPrivateKeyFile(options.privateKey, {
certificate: options.certificate,
publicKey: publicKeyHint,
});
if (materialized?.keyPath) {
identityFilePaths.push(materialized.keyPath);
skTempCleanup = materialized.cleanupDir;
}
} catch (error) {
console.log(`${logPrefix} Could not materialize FIDO2 key handle for agent load`, {
error: error instanceof Error ? error.message : String(error),
});
}
} else if (
isFidoFlow
&& typeof options.certificate === "string"
&& options.certificate.trim()
&& Array.isArray(options.identityFilePaths)
) {
// Path-backed SK + vault certificate: copy handle into temp and stage
// `<key>-cert.pub` so ssh-add advertises the certified identity. Never
// mutate the user's IdentityFile tree.
try {
for (const rawPath of options.identityFilePaths) {
const identityPath = expandIdentityFilePath(rawPath, options);
if (!identityPath || identityPath.endsWith(".pub")) continue;
let privateKeyText = "";
try {
privateKeyText = await fs.promises.readFile(identityPath, "utf8");
} catch {
continue;
}
if (!looksLikeSkOpenSshMaterial(privateKeyText)) continue;
const materialized = await materializeSkPrivateKeyFile(privateKeyText, {
certificate: options.certificate,
publicKey: publicKeyHint,
});
if (materialized?.keyPath) {
identityFilePaths.push(materialized.keyPath);
skTempCleanup = materialized.cleanupDir;
}
break;
}
} catch (error) {
console.log(`${logPrefix} Could not stage FIDO certificate for agent load`, {
error: error instanceof Error ? error.message : String(error),
});
}
}
/** @type {string[]} */
let newlyLoadedIdentityPaths = [];
/** @type {Array<{ key: string, identityPath: string }>} */
let sharedAgentIdentities = [];
let fidoResourcesReleased = false;
const releaseAcquiredFidoResources = () => {
if (fidoResourcesReleased) return;
fidoResourcesReleased = true;
// Netcatty-owned agents outlive a single session (releaseFidoAgent keeps
// the process alive while other sessions hold refs), just like the shared
// Windows system agent. In both cases: drop our hold and only ssh-add -d
// when this was the last session still using that public identity —
// otherwise one session's SK identity stays advertised to the survivors
// (and to any agent-forwarding delegate) after it closed.
if (sharedAgentIdentities.length > 0 && socketPath) {
const {
resolveSshAddBinary,
releaseSharedAgentIdentity,
} = require("./systemSshAgent.cjs");
const sshAdd = fidoSshAddPath || resolveSshAddBinary(process.platform, process.env);
for (const entry of sharedAgentIdentities) {
const { shouldRemove, identityPath, cleanupDir } = releaseSharedAgentIdentity(entry.key);
if (!shouldRemove) continue;
const removeCleanupDir = () => {
if (!cleanupDir) return;
fs.promises.rm(cleanupDir, { recursive: true, force: true }).catch(() => {});
};
if (identityPath) {
try {
// ssh-add -d treats operands as identity-file paths; keep the
// staging dir until the child finishes (or fails to spawn).
childProcess.execFile(
sshAdd,
["-d", identityPath],
{
timeout: 5000,
windowsHide: true,
env: {
...process.env,
SSH_AUTH_SOCK: socketPath,
SSH_ASKPASS_REQUIRE: "never",
},
},
() => {
removeCleanupDir();
},
);
} catch {
removeCleanupDir();
}
} else {
removeCleanupDir();
}
}
}
try {
// Always drop the acquire refcount (owned child or Windows system-pipe slot).
if (acquiredFidoAgent) {
require("./fidoAgentManager.cjs").releaseFidoAgent(ownedFidoAgentGeneration);
}
} catch {
// ignore
}
try {
const askpassLeaseId = askpassEnv?.NETCATTY_FIDO_ASKPASS_LEASE;
if (askpassLeaseId) {
require("./fidoAskpass.cjs").releaseFidoAskpassLease(askpassLeaseId);
}
} catch {
// ignore
}
};
try {
const prepEnv = fidoSshAddPath
? { ...process.env, NETCATTY_SSH_ADD_PATH: fidoSshAddPath }
: process.env;
const agent = await systemSshAgent.prepareSystemSshAgent({
socketPath,
identityFilePaths,
identitiesOnly: options.identitiesOnly,
addKeysToAgent: options.addKeysToAgent,
useKeychain: ownedFidoAgent ? false : options.useKeychain,
agentPublicKeys: isFidoFlow && options.certificate
? [...(options.agentPublicKeys || []), options.certificate]
: options.agentPublicKeys,
loadIdentityFilesIntoAgent: looksLikeSkOpenSshMaterial(options.privateKey)
|| (Array.isArray(options.agentPublicKeys)
&& options.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key)))
|| options.loadIdentityFilesIntoAgent === true
|| ownedFidoAgent
// Windows system-pipe fallback still needs SK handles loaded for ssh2.
|| (!ownedFidoAgent && acquiredFidoAgent),
hostname: options.hostname,
port: options.port,
username: options.username,
}, {
log: (message, details) => console.log(`${logPrefix} ${message}`, details ?? ""),
askpassEnv,
env: prepEnv,
});
if (agent) {
// Expose the socket so Mosh/ET/native OpenSSH can point IdentityAgent at
// the same agent that received ssh-add of SK handles.
agent._netcattyAgentSocket = socketPath;
newlyLoadedIdentityPaths = Array.isArray(agent._netcattyNewlyLoadedIdentityPaths)
? agent._netcattyNewlyLoadedIdentityPaths
: [];
sharedAgentIdentities = Array.isArray(agent._netcattySharedAgentIdentities)
? agent._netcattySharedAgentIdentities
: newlyLoadedIdentityPaths.map((identityPath) => ({
key: identityPath,
identityPath,
}));
if (sharedAgentIdentities.length > 0) {
// Reference-count identities for owned agents too: the owned agent is
// a long-lived singleton, so closing one session must drop only that
// session's hold, not leave its SK identity advertised to survivors.
const { retainSharedAgentIdentity } = require("./systemSshAgent.cjs");
let transferredSkTempCleanup = false;
for (const entry of sharedAgentIdentities) {
const cleanupDir = skTempCleanup
&& typeof entry.identityPath === "string"
&& entry.identityPath.startsWith(skTempCleanup)
? skTempCleanup
: null;
const retained = retainSharedAgentIdentity(entry.key, entry.identityPath, cleanupDir);
// Only the first retainer for a public identity adopts staging cleanup.
// Later sessions materialize a redundant temp dir that must stay on
// skTempCleanup so the finally block can remove it immediately.
if (retained?.acceptedCleanup) {
transferredSkTempCleanup = true;
}
}
if (transferredSkTempCleanup) {
skTempCleanup = null;
}
}
const askpassLeaseId = askpassEnv?.NETCATTY_FIDO_ASKPASS_LEASE;
if (askpassLeaseId) {
const { runWithFidoSigningLease } = require("./fidoAskpass.cjs");
const sign = agent.sign.bind(agent);
agent.sign = (key, data, signOptions, callback) => {
if (typeof signOptions === "function") {
callback = signOptions;
signOptions = undefined;
}
runWithFidoSigningLease(askpassLeaseId, (finish, context) => {
if (fidoResourcesReleased) {
finish();
callback(new Error("FIDO authentication session has ended"));
return;
}
require("./fidoSignFlow.cjs").signWithRecovery(context, options,
settle => sign(key, data, signOptions, settle)).then(result => {
finish();
callback(result.error, result.signature);
}, error => { finish(); callback(error); });
});
};
}
if (ownedFidoAgent || askpassLeaseId || sharedAgentIdentities.length > 0 || acquiredFidoAgent) {
agent._netcattyOwnedFidoAgent = ownedFidoAgent === true;
// Shared lease; quit/exit hooks hard-kill the owned agent as a backstop.
agent._releaseNetcattyFidoAgent = releaseAcquiredFidoResources;
}
} else {
// Acquisition succeeded but preparation produced no agent — drop leases.
releaseAcquiredFidoResources();
}
return agent;
} catch (error) {
// prepareSystemSshAgent can throw after acquire (e.g. IdentitiesOnly without
// a readable .pub selector); release owned agent + askpass before rethrowing.
releaseAcquiredFidoResources();
throw error;
} finally {
if (skTempCleanup) {
// Any session that adopted the staging dir into the identity refcount
// above transfers cleanup (owned and shared agents alike); the last
// release removes the dir after ssh-add -d finishes. Anything left here
// is an unused/redundant temp dir.
fs.promises.rm(skTempCleanup, { recursive: true, force: true }).catch(() => {});
}
}
}
/**
@@ -2082,6 +2598,15 @@ module.exports = {
SSH_KEY_PATTERN,
orderSshIdentityNames,
looksLikePrivateKey,
looksLikeSkOpenSshMaterial,
identityFilesLookLikeSk,
isFidoSkAuthOptions,
buildFidoAwareAgentPrepOptions,
enhanceAuthOptionsForFido,
shouldUseSoftwareCertificateAgent,
resolveWebContentsFromSender,
resolvePreparedAgentSocket,
materializeSkPrivateKeyFile,
isKeyEncrypted,
findDefaultPrivateKey,
findAllDefaultPrivateKeys,
@@ -0,0 +1,600 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
isFidoSkAuthOptions,
buildFidoAwareAgentPrepOptions,
enhanceAuthOptionsForFido,
shouldUseSoftwareCertificateAgent,
resolvePreparedAgentSocket,
looksLikeSkOpenSshMaterial,
identityFilesLookLikeSk,
materializeSkPrivateKeyFile,
} = require("./sshAuthHelper.cjs");
const SK_SSH_ED25519 = "sk-ssh-ed25519@openssh.com";
const SK_ECDSA_NISTP256 = "sk-ecdsa-sha2-nistp256@openssh.com";
const skPub =
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= test";
/** Real OpenSSH shape: algorithm only exists after base64 decode. */
function makeSkPrivatePem(algo) {
const body = Buffer.from(`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${algo}`).toString("base64");
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
assert.equal(pem.includes("@openssh.com"), false, "PEM must not contain plain sk type");
return pem;
}
test("isFidoSkAuthOptions detects public and private sk material", () => {
assert.equal(isFidoSkAuthOptions({ agentPublicKeys: [skPub] }), true);
assert.equal(isFidoSkAuthOptions({
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
}), true);
assert.equal(isFidoSkAuthOptions({ privateKey: "soft-key", useSshAgent: true }), false);
assert.equal(looksLikeSkOpenSshMaterial(skPub), true);
});
test("looksLikeSkOpenSshMaterial detects base64-only sk private PEMs", () => {
const pem = makeSkPrivatePem(SK_SSH_ED25519);
assert.equal(looksLikeSkOpenSshMaterial(pem), true);
assert.equal(isFidoSkAuthOptions({ privateKey: pem }), true);
const soft = "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----";
assert.equal(looksLikeSkOpenSshMaterial(soft), false);
assert.equal(isFidoSkAuthOptions({ privateKey: soft }), false);
});
test("materializeSkPrivateKeyFile writes base64-only sk PEM handles", async () => {
const pem = makeSkPrivatePem(SK_ECDSA_NISTP256);
const result = await materializeSkPrivateKeyFile(pem, {
fs,
os,
path,
tempDirBridge: { getTempDir: () => os.tmpdir() },
});
assert.ok(result?.keyPath, "expected materialized path for real SK PEM");
const written = fs.readFileSync(result.keyPath, "utf8");
assert.equal(written, pem);
fs.rmSync(result.cleanupDir, { recursive: true, force: true });
});
test("buildFidoAwareAgentPrepOptions forces agent + askpass for base64-only SK PEMs", () => {
const sender = { id: 1, isDestroyed: () => false };
const pem = makeSkPrivatePem(SK_SSH_ED25519);
const prep = buildFidoAwareAgentPrepOptions({
useSshAgent: false,
privateKey: pem,
}, sender);
assert.equal(prep.useSshAgent, true);
assert.equal(prep.useFidoAgent, true);
assert.equal(prep.loadIdentityFilesIntoAgent, true);
assert.equal(prep.addKeysToAgent, "yes");
assert.equal(typeof prep.resolveWebContents, "function");
assert.equal(prep.resolveWebContents(), sender);
});
test("buildFidoAwareAgentPrepOptions leaves soft keys alone", () => {
const prep = buildFidoAwareAgentPrepOptions({
useSshAgent: false,
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----",
});
assert.equal(prep.useSshAgent, false);
assert.equal(prep.useFidoAgent, false);
});
test("resolvePreparedAgentSocket prefers agent annotation", () => {
assert.equal(
resolvePreparedAgentSocket({ _netcattyAgentSocket: "/tmp/fido.sock" }),
"/tmp/fido.sock",
);
assert.equal(resolvePreparedAgentSocket(null), null);
});
test("identityFilesLookLikeSk peeks .pub and private handle for path-only SK keys", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-sk-path-"));
const keyPath = path.join(dir, "id_ed25519_sk");
const pubPath = `${keyPath}.pub`;
fs.writeFileSync(pubPath, skPub);
fs.writeFileSync(keyPath, makeSkPrivatePem(SK_SSH_ED25519));
try {
assert.equal(await identityFilesLookLikeSk([keyPath]), true);
assert.equal(await identityFilesLookLikeSk([pubPath]), true);
assert.equal(await identityFilesLookLikeSk([path.join(dir, "missing")]), false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("looksLikeSkOpenSshMaterial recognizes sk certificate public keys", () => {
assert.equal(
looksLikeSkOpenSshMaterial(
"sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA user",
),
true,
);
});
test("prepareSystemSshAgentForAuth does not probe IdentityFiles for password auth", async () => {
const { prepareSystemSshAgentForAuth } = require("./sshAuthHelper.cjs");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-sk-pw-"));
const keyPath = path.join(dir, "id_ed25519_sk");
fs.writeFileSync(`${keyPath}.pub`, skPub);
try {
const agent = await prepareSystemSshAgentForAuth({
authMethod: "password",
useSshAgent: false,
identityFilePaths: [keyPath],
}, "[test]");
assert.equal(agent, null);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("enhanceAuthOptionsForFido forces agent for path-only IdentityFile SK keys", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-sk-enhance-"));
const keyPath = path.join(dir, "id_ed25519_sk");
fs.writeFileSync(`${keyPath}.pub`, skPub);
try {
const prep = await enhanceAuthOptionsForFido({
useSshAgent: false,
identityFilePaths: [keyPath],
});
assert.equal(prep.useSshAgent, true);
assert.equal(prep.useFidoAgent, true);
assert.equal(prep.loadIdentityFilesIntoAgent, true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("shouldUseSoftwareCertificateAgent is false for FIDO SK certificates", () => {
assert.equal(
shouldUseSoftwareCertificateAgent({ certificate: "sk-ssh-ed25519-cert-v01@openssh.com AAAA" }, true),
false,
);
assert.equal(
shouldUseSoftwareCertificateAgent({ certificate: "ssh-ed25519-cert-v01@openssh.com AAAA" }, false),
true,
);
});
test("materializeSkPrivateKeyFile stages companion certificate for ssh-add", async () => {
const pem = makeSkPrivatePem(SK_SSH_ED25519);
const cert = "sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA user";
const result = await materializeSkPrivateKeyFile(pem, {
fs,
path,
tempDirBridge: { getTempDir: () => os.tmpdir() },
certificate: cert,
});
assert.ok(result?.keyPath);
assert.equal(fs.readFileSync(`${result.keyPath}-cert.pub`, "utf8").trim(), cert);
fs.rmSync(result.cleanupDir, { recursive: true, force: true });
});
test("materializeSkPrivateKeyFile fails closed without managed temp dir", async () => {
const pem = makeSkPrivatePem(SK_SSH_ED25519);
await assert.rejects(
() => materializeSkPrivateKeyFile(pem, {
fs,
path,
tempDirBridge: {},
}),
(error) => error?.code === "ERR_FIDO_TEMP_DIR_UNAVAILABLE",
);
});
test("prepareSystemSshAgentForAuth releases FIDO resources when preparation throws", async () => {
const fidoAgentManager = require("./fidoAgentManager.cjs");
const fidoAskpass = require("./fidoAskpass.cjs");
const originalAcquire = fidoAgentManager.acquireFidoAgent;
const originalRelease = fidoAgentManager.releaseFidoAgent;
const originalReleaseLease = fidoAskpass.releaseFidoAskpassLease;
const leaseId = "test-lease-prep-fail";
let releasedAgent = 0;
let releasedLease = 0;
fidoAgentManager.acquireFidoAgent = async () => ({
socketPath: "/tmp/netcatty-fake-fido.sock",
askpassEnv: {
SSH_ASKPASS: "/bin/true",
NETCATTY_FIDO_ASKPASS_LEASE: leaseId,
},
owned: true,
generation: 1,
});
fidoAgentManager.releaseFidoAgent = () => { releasedAgent += 1; };
fidoAskpass.releaseFidoAskpassLease = (id) => {
if (id === leaseId) releasedLease += 1;
};
try {
const { prepareSystemSshAgentForAuth } = require("./sshAuthHelper.cjs");
await assert.rejects(
() => prepareSystemSshAgentForAuth({
useSshAgent: true,
useFidoAgent: true,
identitiesOnly: true,
identityFilePaths: [],
}, "[test]"),
(error) => error?.code === "ERR_SSH_AGENT_IDENTITY_SELECTOR_UNAVAILABLE",
);
assert.equal(releasedAgent, 1);
assert.equal(releasedLease, 1);
} finally {
fidoAgentManager.acquireFidoAgent = originalAcquire;
fidoAgentManager.releaseFidoAgent = originalRelease;
fidoAskpass.releaseFidoAskpassLease = originalReleaseLease;
}
});
test("owned FIDO agent reference-counts identities: ssh-add -d only on the last session release", async () => {
const fidoAgentManager = require("./fidoAgentManager.cjs");
const fidoAskpass = require("./fidoAskpass.cjs");
const systemSshAgent = require("./systemSshAgent.cjs");
const childProcess = require("node:child_process");
const originalAcquire = fidoAgentManager.acquireFidoAgent;
const originalRelease = fidoAgentManager.releaseFidoAgent;
const originalReleaseLease = fidoAskpass.releaseFidoAskpassLease;
const originalPrepare = systemSshAgent.prepareSystemSshAgent;
const originalExecFile = childProcess.execFile;
const skPub =
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= test";
const blob = systemSshAgent.publicKeyBlob(skPub);
const pathA = "/tmp/netcatty-owned-sk-a";
const pathB = "/tmp/netcatty-owned-sk-b";
const deleted = [];
let generation = 0;
// The owned agent is a Netcatty-managed singleton shared across concurrent
// sessions; releaseFidoAgent only drops one refcount and keeps it alive.
fidoAgentManager.acquireFidoAgent = async () => {
generation += 1;
return {
socketPath: "/tmp/netcatty-owned-agent.sock",
askpassEnv: {
SSH_ASKPASS: "/bin/true",
NETCATTY_FIDO_ASKPASS_LEASE: `lease-${generation}`,
},
owned: true,
generation,
};
};
fidoAgentManager.releaseFidoAgent = () => {};
fidoAskpass.releaseFidoAskpassLease = () => {};
let prepCount = 0;
systemSshAgent.prepareSystemSshAgent = async () => {
prepCount += 1;
const identityPath = prepCount === 1 ? pathA : pathB;
return {
getIdentities: (cb) => cb(null, []),
sign: (_k, _d, _o, cb) => cb(new Error("unused")),
_netcattyNewlyLoadedIdentityPaths: [identityPath],
_netcattySharedAgentIdentities: [{ key: blob, identityPath }],
};
};
childProcess.execFile = (file, args, opts, cb) => {
if (Array.isArray(args) && args[0] === "-d") {
deleted.push(args[1]);
if (typeof cb === "function") cb(null, "", "");
return { kill() {} };
}
return originalExecFile(file, args, opts, cb);
};
try {
systemSshAgent.resetSharedAgentIdentityRefsForTests();
const { prepareSystemSshAgentForAuth } = require("./sshAuthHelper.cjs");
const agentA = await prepareSystemSshAgentForAuth({
useSshAgent: true,
useFidoAgent: true,
agentPublicKeys: [skPub],
loadIdentityFilesIntoAgent: true,
identityFilePaths: [pathA],
}, "[test-owned-a]");
const agentB = await prepareSystemSshAgentForAuth({
useSshAgent: true,
useFidoAgent: true,
agentPublicKeys: [skPub],
loadIdentityFilesIntoAgent: true,
identityFilePaths: [pathB],
}, "[test-owned-b]");
// Closing session A must not leave A's identity advertised to session B.
agentA._releaseNetcattyFidoAgent();
assert.deepEqual(deleted, [], "first release must keep the identity for the surviving session");
agentB._releaseNetcattyFidoAgent();
assert.deepEqual(deleted, [pathA], "last release removes the identity from the owned agent");
} finally {
fidoAgentManager.acquireFidoAgent = originalAcquire;
fidoAgentManager.releaseFidoAgent = originalRelease;
fidoAskpass.releaseFidoAskpassLease = originalReleaseLease;
systemSshAgent.prepareSystemSshAgent = originalPrepare;
childProcess.execFile = originalExecFile;
systemSshAgent.resetSharedAgentIdentityRefsForTests();
}
});
test("prepareSystemSshAgentForAuth removes newly loaded identities from a shared Windows agent on release", async () => {
const fidoAgentManager = require("./fidoAgentManager.cjs");
const fidoAskpass = require("./fidoAskpass.cjs");
const systemSshAgent = require("./systemSshAgent.cjs");
const childProcess = require("node:child_process");
const originalAcquire = fidoAgentManager.acquireFidoAgent;
const originalRelease = fidoAgentManager.releaseFidoAgent;
const originalReleaseLease = fidoAskpass.releaseFidoAskpassLease;
const originalPrepare = systemSshAgent.prepareSystemSshAgent;
const originalExecFile = childProcess.execFile;
const leaseId = "test-lease-shared-win";
const identityPath = "/tmp/netcatty-shared-sk";
const skPub =
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= test";
const deleted = [];
let releasedAgent = 0;
fidoAgentManager.acquireFidoAgent = async () => ({
socketPath: "\\\\.\\pipe\\openssh-ssh-agent",
askpassEnv: {
SSH_ASKPASS: "/bin/true",
NETCATTY_FIDO_ASKPASS_LEASE: leaseId,
},
owned: false,
generation: 7,
});
fidoAgentManager.releaseFidoAgent = (generation) => {
assert.equal(generation, 7);
releasedAgent += 1;
};
fidoAskpass.releaseFidoAskpassLease = () => {};
systemSshAgent.prepareSystemSshAgent = async () => ({
getIdentities: (cb) => cb(null, []),
sign: (_k, _d, _o, cb) => cb(new Error("unused")),
_netcattyNewlyLoadedIdentityPaths: [identityPath],
});
childProcess.execFile = (file, args, opts, cb) => {
if (Array.isArray(args) && args[0] === "-d") {
deleted.push(args[1]);
if (typeof cb === "function") cb(null, "", "");
return { kill() {} };
}
return originalExecFile(file, args, opts, cb);
};
try {
systemSshAgent.resetSharedAgentIdentityRefsForTests?.();
const { prepareSystemSshAgentForAuth } = require("./sshAuthHelper.cjs");
const agent = await prepareSystemSshAgentForAuth({
useSshAgent: true,
useFidoAgent: true,
agentPublicKeys: [skPub],
loadIdentityFilesIntoAgent: true,
identityFilePaths: [identityPath],
}, "[test]");
assert.ok(agent);
assert.equal(typeof agent._releaseNetcattyFidoAgent, "function");
agent._releaseNetcattyFidoAgent();
assert.deepEqual(deleted, [identityPath]);
assert.equal(releasedAgent, 1);
} finally {
fidoAgentManager.acquireFidoAgent = originalAcquire;
fidoAgentManager.releaseFidoAgent = originalRelease;
fidoAskpass.releaseFidoAskpassLease = originalReleaseLease;
systemSshAgent.prepareSystemSshAgent = originalPrepare;
childProcess.execFile = originalExecFile;
systemSshAgent.resetSharedAgentIdentityRefsForTests?.();
}
});
test("shared Windows agent defers ssh-add -d until the last identity user releases", async () => {
const fidoAgentManager = require("./fidoAgentManager.cjs");
const fidoAskpass = require("./fidoAskpass.cjs");
const systemSshAgent = require("./systemSshAgent.cjs");
const childProcess = require("node:child_process");
const { publicKeyBlob } = systemSshAgent;
const originalAcquire = fidoAgentManager.acquireFidoAgent;
const originalRelease = fidoAgentManager.releaseFidoAgent;
const originalReleaseLease = fidoAskpass.releaseFidoAskpassLease;
const originalPrepare = systemSshAgent.prepareSystemSshAgent;
const originalExecFile = childProcess.execFile;
const skPub =
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= test";
const blob = publicKeyBlob(skPub);
const pathA = "/tmp/netcatty-shared-sk-a";
const pathB = "/tmp/netcatty-shared-sk-b";
const deleted = [];
let generation = 0;
fidoAgentManager.acquireFidoAgent = async () => {
generation += 1;
return {
socketPath: "\\\\.\\pipe\\openssh-ssh-agent",
askpassEnv: {
SSH_ASKPASS: "/bin/true",
NETCATTY_FIDO_ASKPASS_LEASE: `lease-${generation}`,
},
owned: false,
generation,
};
};
fidoAgentManager.releaseFidoAgent = () => {};
fidoAskpass.releaseFidoAskpassLease = () => {};
let prepCount = 0;
systemSshAgent.prepareSystemSshAgent = async () => {
prepCount += 1;
const identityPath = prepCount === 1 ? pathA : pathB;
return {
getIdentities: (cb) => cb(null, []),
sign: (_k, _d, _o, cb) => cb(new Error("unused")),
_netcattyNewlyLoadedIdentityPaths: [identityPath],
_netcattySharedAgentIdentities: [{ key: blob, identityPath }],
};
};
childProcess.execFile = (file, args, opts, cb) => {
if (Array.isArray(args) && args[0] === "-d") {
deleted.push(args[1]);
if (typeof cb === "function") cb(null, "", "");
return { kill() {} };
}
return originalExecFile(file, args, opts, cb);
};
try {
systemSshAgent.resetSharedAgentIdentityRefsForTests();
const { prepareSystemSshAgentForAuth } = require("./sshAuthHelper.cjs");
const agentA = await prepareSystemSshAgentForAuth({
useSshAgent: true,
useFidoAgent: true,
agentPublicKeys: [skPub],
loadIdentityFilesIntoAgent: true,
identityFilePaths: [pathA],
}, "[test-a]");
const agentB = await prepareSystemSshAgentForAuth({
useSshAgent: true,
useFidoAgent: true,
agentPublicKeys: [skPub],
loadIdentityFilesIntoAgent: true,
identityFilePaths: [pathB],
}, "[test-b]");
agentA._releaseNetcattyFidoAgent();
assert.deepEqual(deleted, [], "first release must keep the shared identity loaded");
agentB._releaseNetcattyFidoAgent();
assert.deepEqual(deleted, [pathA], "last release removes the identity via the retained path");
} finally {
fidoAgentManager.acquireFidoAgent = originalAcquire;
fidoAgentManager.releaseFidoAgent = originalRelease;
fidoAskpass.releaseFidoAskpassLease = originalReleaseLease;
systemSshAgent.prepareSystemSshAgent = originalPrepare;
childProcess.execFile = originalExecFile;
systemSshAgent.resetSharedAgentIdentityRefsForTests();
}
});
test("shared Windows agent keeps staging dir until ssh-add -d finishes", async () => {
const fidoAgentManager = require("./fidoAgentManager.cjs");
const fidoAskpass = require("./fidoAskpass.cjs");
const systemSshAgent = require("./systemSshAgent.cjs");
const childProcess = require("node:child_process");
const originalAcquire = fidoAgentManager.acquireFidoAgent;
const originalRelease = fidoAgentManager.releaseFidoAgent;
const originalReleaseLease = fidoAskpass.releaseFidoAskpassLease;
const originalPrepare = systemSshAgent.prepareSystemSshAgent;
const originalRetain = systemSshAgent.retainSharedAgentIdentity;
const originalExecFile = childProcess.execFile;
const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-sk-staging-"));
const identityPath = path.join(stagingDir, "id_sk");
fs.writeFileSync(identityPath, makeSkPrivatePem(SK_SSH_ED25519), { mode: 0o600 });
/** @type {null | ((err: Error|null, stdout: string, stderr: string) => void)} */
let pendingDeleteCb = null;
fidoAgentManager.acquireFidoAgent = async () => ({
socketPath: "\\\\.\\pipe\\openssh-ssh-agent",
askpassEnv: {
SSH_ASKPASS: "/bin/true",
NETCATTY_FIDO_ASKPASS_LEASE: "test-lease-staging-cleanup",
},
owned: false,
generation: 9,
});
fidoAgentManager.releaseFidoAgent = () => {};
fidoAskpass.releaseFidoAskpassLease = () => {};
// Prep is mocked (no real ssh-add); still attach staging cleanup the way
// materializeSkPrivateKeyFile + retainSharedAgentIdentity would in production.
systemSshAgent.retainSharedAgentIdentity = (key, idPath, cleanupDir) => (
originalRetain(key, idPath, cleanupDir || stagingDir)
);
systemSshAgent.prepareSystemSshAgent = async () => ({
getIdentities: (cb) => cb(null, []),
sign: (_k, _d, _o, cb) => cb(new Error("unused")),
_netcattyNewlyLoadedIdentityPaths: [identityPath],
});
childProcess.execFile = (file, args, opts, cb) => {
if (Array.isArray(args) && args[0] === "-d") {
pendingDeleteCb = typeof cb === "function" ? cb : null;
return { kill() {} };
}
return originalExecFile(file, args, opts, cb);
};
try {
systemSshAgent.resetSharedAgentIdentityRefsForTests?.();
const { prepareSystemSshAgentForAuth } = require("./sshAuthHelper.cjs");
const agent = await prepareSystemSshAgentForAuth({
useSshAgent: true,
useFidoAgent: true,
agentPublicKeys: [skPub],
loadIdentityFilesIntoAgent: true,
identityFilePaths: [identityPath],
}, "[test-staging-cleanup]");
assert.ok(agent);
assert.equal(typeof agent._releaseNetcattyFidoAgent, "function");
agent._releaseNetcattyFidoAgent();
assert.ok(pendingDeleteCb, "expected ssh-add -d to start");
assert.equal(
fs.existsSync(stagingDir),
true,
"staging must survive until ssh-add -d returns",
);
assert.equal(fs.existsSync(identityPath), true);
pendingDeleteCb(null, "", "");
for (let i = 0; i < 40 && fs.existsSync(stagingDir); i += 1) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.equal(
fs.existsSync(stagingDir),
false,
"staging removed after ssh-add -d completes",
);
} finally {
fidoAgentManager.acquireFidoAgent = originalAcquire;
fidoAgentManager.releaseFidoAgent = originalRelease;
fidoAskpass.releaseFidoAskpassLease = originalReleaseLease;
systemSshAgent.prepareSystemSshAgent = originalPrepare;
systemSshAgent.retainSharedAgentIdentity = originalRetain;
childProcess.execFile = originalExecFile;
systemSshAgent.resetSharedAgentIdentityRefsForTests?.();
fs.rmSync(stagingDir, { recursive: true, force: true });
}
});
test("materializeSkPrivateKeyFile writes companion .pub from publicKey hint", async () => {
const pem = makeSkPrivatePem(SK_SSH_ED25519);
const result = await materializeSkPrivateKeyFile(pem, {
fs,
path,
tempDirBridge: { getTempDir: () => os.tmpdir() },
publicKey: skPub,
});
assert.ok(result?.keyPath);
assert.equal(fs.readFileSync(`${result.keyPath}.pub`, "utf8").trim(), skPub.trim());
fs.rmSync(result.cleanupDir, { recursive: true, force: true });
});
test("FIDO IdentityFile probing expands OpenSSH tokens with the connection context", async () => {
const tempDirBridge = require("./tempDirBridge.cjs");
const dir = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "sk-path-"));
const context = { hostname: "server.example", port: 2222, username: "deploy" };
const keyPath = path.join(dir, "id_%h_%p_%r_%%");
fs.writeFileSync(path.join(dir, "id_server.example_2222_deploy_%.pub"), skPub);
try {
assert.equal(await identityFilesLookLikeSk([keyPath], context), true);
assert.equal(await identityFilesLookLikeSk([keyPath], { ...context, username: "other" }), false);
const prep = await enhanceAuthOptionsForFido({
...context, identityFilePaths: [keyPath], useSshAgent: false,
});
assert.equal(prep.useFidoAgent, true);
assert.equal(prep.useSshAgent, true);
assert.equal((await enhanceAuthOptionsForFido({
...context, identityFilePaths: [keyPath], authMethod: "password", useSshAgent: false,
})).useSshAgent, false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
+67 -5
View File
@@ -15,6 +15,7 @@ const { Client: SSHClient, utils: sshUtils } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
const fidoPromptHandler = require("./fidoPromptHandler.cjs");
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
const { createProxySocket, runWhenProxyConnectionReady } = require("./proxyUtils.cjs");
const { attachX11Forwarding } = require("./x11Forwarding.cjs");
@@ -43,6 +44,8 @@ const {
loadFirstIdentityFileForAuth,
hasUserConfiguredKey,
isPasswordProvided,
looksLikeSkOpenSshMaterial,
identityFilesLookLikeSk,
PassphraseCancelledError,
isPassphraseCancelledError,
} = require("./sshAuthHelper.cjs");
@@ -617,9 +620,38 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
const systemAuthAgent = hasCertificate
let isJumpFidoSk = looksLikeSkOpenSshMaterial(jump.privateKey)
|| (Array.isArray(jump.agentPublicKeys)
&& jump.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key)))
|| jump.useFidoAgent === true;
if (!isJumpFidoSk && jump.authMethod !== "password"
&& Array.isArray(jump.identityFilePaths)
&& jump.identityFilePaths.length > 0) {
try {
isJumpFidoSk = await identityFilesLookLikeSk(jump.identityFilePaths);
} catch {
// ignore
}
}
const forceJumpFidoAgent = isJumpFidoSk && jump.authMethod !== "password";
// FIDO SK + certificate still needs the agent for hardware signing.
const systemAuthAgent = (hasCertificate && !forceJumpFidoAgent)
? null
: await prepareSystemSshAgentForAuth(jump, `[Chain] Hop ${i + 1}:`);
: await prepareSystemSshAgentForAuth({
...jump,
useSshAgent: forceJumpFidoAgent ? true : jump.useSshAgent,
useFidoAgent: forceJumpFidoAgent || jump.useFidoAgent === true,
loadIdentityFilesIntoAgent: forceJumpFidoAgent || jump.loadIdentityFilesIntoAgent,
addKeysToAgent: jump.addKeysToAgent || (forceJumpFidoAgent ? "yes" : jump.addKeysToAgent),
resolveWebContents: () => {
try {
return sender && !sender.isDestroyed?.() ? sender : null;
} catch {
return null;
}
},
}, `[Chain] Hop ${i + 1}:`);
const identityFile = !jump.privateKey && !systemAuthAgent
? await loadFirstIdentityFileForAuth({
@@ -648,7 +680,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
},
})
: null;
const inlineKey = jump.privateKey && !systemAuthAgent
const inlineKey = jump.privateKey
&& !systemAuthAgent
&& !looksLikeSkOpenSshMaterial(jump.privateKey)
? await preparePrivateKeyForAuth({
sender,
privateKey: jump.privateKey,
@@ -674,8 +708,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
let authAgent = null;
if (systemAuthAgent) {
connOpts.agent = systemAuthAgent;
require("./attachFidoAgentRelease.cjs").attachFidoAgentRelease(conn, systemAuthAgent);
}
if (hasCertificate) {
if (hasCertificate && !forceJumpFidoAgent) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
@@ -687,7 +722,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
},
});
connOpts.agent = authAgent;
} else if (effectivePrivateKey) {
} else if (effectivePrivateKey && !forceJumpFidoAgent) {
connOpts.privateKey = effectivePrivateKey;
if (effectivePassphrase) {
connOpts.passphrase = effectivePassphrase;
@@ -1016,6 +1051,25 @@ async function generateKeyPair(event, options) {
const { type, bits, comment } = options;
try {
// FIDO2 / security-key types require OpenSSH + libfido2 hardware interaction.
// Generate via ssh-keygen so the key handle is created on the token.
if (type === "ED25519-SK" || type === "ECDSA-SK") {
const { generateFidoSshKeyPair } = require("./fidoSshKeygen.cjs");
return await generateFidoSshKeyPair({
type,
comment: comment || "netcatty-fido-key",
resident: options?.resident === true,
verifyRequired: options?.verifyRequired === true,
resolveWebContents: () => {
try {
return event?.sender && !event.sender.isDestroyed?.() ? event.sender : null;
} catch {
return null;
}
},
});
}
let keyType;
let keyBits = bits;
@@ -1553,6 +1607,12 @@ function registerHandlers(ipcMain, options = {}) {
"netcatty:host-key:respond",
hostKeyVerifier,
);
registerOwnedAuthResponseHandler(
ipcMain,
terminalWorkerManager,
"netcatty:fido-prompt:respond",
fidoPromptHandler,
);
ipcMain.on("netcatty:zmodem:overwrite-response", (event, payload) => {
terminalWorkerManager.send("netcatty:zmodem:overwrite-response", payload, {
webContentsId: event?.sender?.id,
@@ -1576,6 +1636,8 @@ function registerHandlers(ipcMain, options = {}) {
keyboardInteractiveHandler.registerHandler(ipcMain);
// Register the passphrase response handler
passphraseHandler.registerHandler(ipcMain);
// FIDO2 PIN / touch presence prompts (ssh-sk-helper / ssh-add / ssh-keygen)
fidoPromptHandler.registerHandler(ipcMain);
// Register the SSH host key verification response handler
hostKeyVerifier.registerHandler(ipcMain);
}
+8 -4
View File
@@ -18,9 +18,12 @@ function createExecCommandApi(ctx) {
: payload.useSshAgent === true
? undefined
: await getAvailableAgentSocket();
const systemAuthAgent = hasCertificate
const { enhanceAuthOptionsForFido } = require("../sshAuthHelper.cjs");
const prep = await enhanceAuthOptionsForFido(payload, sender);
const isFido = prep.useFidoAgent === true;
const systemAuthAgent = (hasCertificate && !isFido)
? null
: await prepareSystemSshAgentForAuth(payload, "[SSH Exec]");
: await prepareSystemSshAgentForAuth(prep, "[SSH Exec]");
const defaultKeys = enableKeyboardInteractive && !(systemAuthAgent && payload.identitiesOnly)
? await findAllDefaultPrivateKeysFromHelper()
: [];
@@ -146,7 +149,8 @@ function createExecCommandApi(ctx) {
const effectivePassphrase = inlineKey?.passphrase || identityFilePassphrase;
if (systemAuthAgent) {
connectOpts.agent = systemAuthAgent;
} else if (hasCertificate) {
require("../attachFidoAgentRelease.cjs").attachFidoAgentRelease(conn, systemAuthAgent);
} else if (hasCertificate && !isFido) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
@@ -158,7 +162,7 @@ function createExecCommandApi(ctx) {
},
});
connectOpts.agent = authAgent;
} else if (effectivePrivateKey) {
} else if (effectivePrivateKey && !isFido) {
connectOpts.privateKey = effectivePrivateKey;
if (effectivePassphrase) {
connectOpts.passphrase = effectivePassphrase;
@@ -49,6 +49,10 @@ function createMoshStatsConnectionApi(ctx) {
// when not wired in (e.g. older callers / some unit tests) the verifier
// simply skips the system-known_hosts fallback.
const isHostKeyTrustedBySystem = ctx.isHostKeyTrustedBySystem;
// FIDO2 sk-* key handles parse as OpenSSH keys but can never sign locally —
// signing stays on the hardware token via ssh-add / the FIDO agent. The
// stats companion must not build a software certificate agent from them.
const { looksLikeSkOpenSshMaterial } = require("../sshAuthHelper.cjs");
with (ctx) {
// Resolve a usable, non-interactive private key (+ passphrase) for the
// companion connection. Returns null when the key is missing, encrypted
@@ -213,14 +217,24 @@ function createMoshStatsConnectionApi(ctx) {
const hasCertificate =
typeof auth.certificate === "string" && auth.certificate.trim().length > 0;
const allowLocalKeyFallbackWithAgent = auth.authMethod === "auto";
const inlineKey = auth.useSshAgent && !allowLocalKeyFallbackWithAgent
let inlineKey = auth.useSshAgent && !allowLocalKeyFallbackWithAgent
? null
: resolveNonInteractiveKey(auth.privateKey, auth.passphrase);
// SK key handles parse non-interactively but cannot sign locally. A
// software NetcattyAgent built around one always fails signing — and its
// non-null presence would skip prepareSystemSshAgentForAuth() below, so
// the companion would never use the hardware-backed agent that the
// Mosh/ET session itself authenticated with. Route SK material to the
// prepared agent instead.
if (inlineKey && looksLikeSkOpenSshMaterial(inlineKey.privateKey)) {
inlineKey = null;
}
const keys = inlineKey
? [inlineKey]
: auth.useSshAgent && !allowLocalKeyFallbackWithAgent
? []
: await resolveNonInteractiveIdentityFiles(auth.identityFilePaths, auth.passphrase);
: (await resolveNonInteractiveIdentityFiles(auth.identityFilePaths, auth.passphrase))
.filter((candidate) => !looksLikeSkOpenSshMaterial(candidate.privateKey));
const key = keys[0] || null;
let agent = null;
@@ -250,7 +264,15 @@ function createMoshStatsConnectionApi(ctx) {
}
if (!agent && auth.useSshAgent && typeof prepareSystemSshAgentForAuth === "function") {
connectOpts.agent = await prepareSystemSshAgentForAuth(auth, `[${label} Stats]`);
const prepared = await prepareSystemSshAgentForAuth(auth, `[${label} Stats]`);
connectOpts.agent = prepared;
if (typeof prepared?._releaseNetcattyFidoAgent === "function") {
// Surface the release hook on connectOpts so establishStatsConnection
// can drop the owned FIDO agent / askpass lease on close, error, or
// sessionGone — this consumer never attaches the agent to a long-lived
// SSHClient lifecycle helper.
connectOpts._releaseNetcattyFidoAgent = prepared._releaseNetcattyFidoAgent;
}
}
if (typeof auth.password === "string" && auth.password.length > 0) {
@@ -400,15 +422,30 @@ function createMoshStatsConnectionApi(ctx) {
...auth,
webContents,
}, opts.label);
const releaseStatsFidoAgent = (() => {
const releaseFn = connectOpts?._releaseNetcattyFidoAgent;
if (typeof releaseFn !== "function") return () => {};
let released = false;
return () => {
if (released) return;
released = true;
try { releaseFn(); } catch { /* ignore */ }
};
})();
// Do not forward the internal release hook into ssh2 connect options.
delete connectOpts._releaseNetcattyFidoAgent;
if (!hasAnyAuth) {
// Nothing we can authenticate with non-interactively (e.g. the user
// typed a password into the Mosh handshake PTY that we never stored).
releaseStatsFidoAgent();
session[opts.failedProp] = true;
return null;
}
// The session may have been closed while we were reading identity files.
if (sessionGone(session, sessionId)) {
releaseStatsFidoAgent();
return null;
}
@@ -449,6 +486,7 @@ function createMoshStatsConnectionApi(ctx) {
// retry.
const fail = (err, permanent) => {
try { conn.end(); } catch { /* ignore */ }
releaseStatsFidoAgent();
if (permanent) session[opts.failedProp] = true;
finish(null);
};
@@ -457,6 +495,7 @@ function createMoshStatsConnectionApi(ctx) {
// The session may have been closed while we were connecting.
if (sessionGone(session, sessionId)) {
try { conn.end(); } catch { /* ignore */ }
releaseStatsFidoAgent();
finish(null);
return;
}
@@ -481,6 +520,7 @@ function createMoshStatsConnectionApi(ctx) {
conn.on("close", () => {
if (session[opts.connProp] === conn) session[opts.connProp] = null;
releaseStatsFidoAgent();
// If the socket closed mid-handshake without ever emitting "ready"
// or "error", settle the attempt here so the awaiting getServerStats
// call (and the in-flight promise on opts.promiseProp) don't hang
@@ -320,6 +320,71 @@ test("automatic stats auth keeps local-key fallback with an explicit agent", asy
]);
});
test("sk-* key handle + certificate routes to the prepared hardware-backed agent, not a software cert agent", async () => {
let agentConstructions = 0;
const { api, sessions } = makeApi({
NetcattyAgent: class {
constructor() {
agentConstructions += 1;
}
},
prepareSystemSshAgentForAuth: async () => "/tmp/prepared-fido-agent.sock",
});
const session = {
moshStatsAuth: {
hostname: "h",
username: "u",
authMethod: "auto",
useSshAgent: true,
// SK private material parses non-interactively but can never sign
// locally — building a software certificate agent around it fails auth
// and blocks prepareSystemSshAgentForAuth below.
privateKey: "sk-ssh-ed25519@openssh.com AAAA handle test",
certificate: "sk-ssh-ed25519-cert-v01@openssh.com AAAA cert test",
identityFilePaths: [],
},
};
sessions.set("sid", session);
api.ensureMoshStatsConnection(session, "sid");
await tick();
await tick();
assert.equal(agentConstructions, 0, "software certificate agent must not be built from an SK handle");
assert.equal(FakeSSHClient.instances[0].connectOpts.agent, "/tmp/prepared-fido-agent.sock");
});
test("path-backed sk-* key handles are filtered out of the companion's identity files too", async () => {
let agentConstructions = 0;
const { api, sessions } = makeApi({
NetcattyAgent: class {
constructor() {
agentConstructions += 1;
}
},
readFileNoFollow: async () => "sk-ecdsa-sha2-nistp256@openssh.com AAAA handle test",
prepareSystemSshAgentForAuth: async () => "/tmp/prepared-fido-agent.sock",
});
const session = {
moshStatsAuth: {
hostname: "h",
username: "u",
authMethod: "auto",
useSshAgent: true,
certificate: "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com AAAA cert test",
identityFilePaths: ["~/.ssh/id_sk"],
},
};
sessions.set("sid", session);
api.ensureMoshStatsConnection(session, "sid");
await tick();
await tick();
assert.equal(agentConstructions, 0, "software certificate agent must not be built from a path-backed SK handle");
assert.equal(FakeSSHClient.instances[0].connectOpts.agent, "/tmp/prepared-fido-agent.sock");
});
test("falls back to ssh-agent when a socket is available and no inline creds", async () => {
// The system ssh used by the Mosh handshake authenticates via the local
// agent by default, so the companion should too — regardless of the
@@ -686,6 +751,70 @@ test("a connection that becomes ready after the session closed is discarded", as
assert.equal(session.moshStatsConn, undefined);
});
test("releases FIDO agent resources when the stats companion closes", async () => {
let releaseCount = 0;
const { api, sessions } = makeApi({
prepareSystemSshAgentForAuth: async () => ({
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
}),
});
const session = {
moshStatsAuth: {
hostname: "h",
username: "u",
useSshAgent: true,
},
};
sessions.set("sid", session);
const pending = api.ensureMoshStatsConnection(session, "sid");
await tick();
await tick();
const client = FakeSSHClient.instances[0];
assert.ok(client.connectOpts.agent);
assert.equal(typeof client.connectOpts._releaseNetcattyFidoAgent, "undefined");
client.emitReady();
assert.equal(await pending, client);
assert.equal(releaseCount, 0);
client.emit("close");
assert.equal(releaseCount, 1);
// close/end fan-out must stay one-shot
client.emit("close");
assert.equal(releaseCount, 1);
});
test("releases FIDO agent resources when stats connect is abandoned via sessionGone", async () => {
let releaseCount = 0;
let resolvePrepare;
const prepareGate = new Promise((resolve) => { resolvePrepare = resolve; });
const { api, sessions } = makeApi({
prepareSystemSshAgentForAuth: async () => {
await prepareGate;
return {
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
};
},
});
const session = {
moshStatsAuth: {
hostname: "h",
username: "u",
useSshAgent: true,
},
};
sessions.set("sid", session);
const pending = api.ensureMoshStatsConnection(session, "sid");
await tick();
session.closed = true;
sessions.delete("sid");
resolvePrepare();
assert.equal(await pending, null);
assert.equal(releaseCount, 1);
assert.equal(FakeSSHClient.instances.length, 0);
});
test("honors host algorithm settings via buildAlgorithms", async () => {
const calls = [];
const { api, sessions } = makeApi({
@@ -138,6 +138,30 @@ test("direct SSH allows only a restricted selected agent-backed key", () => {
}, { agent: {} }), false);
});
test("FIDO agent login is offered after path/inline sk detection even when renderer opted out of ssh-agent", () => {
const pathOnlySk = {
authMethod: "key",
useSshAgent: false,
useFidoAgent: true,
identitiesOnly: true,
identityFilePaths: ["~/.ssh/id_ed25519_sk"],
};
assert.equal(shouldOfferAgentForLogin(pathOnlySk, { agent: {} }), true);
const inlineSk = {
authMethod: "key",
useSshAgent: false,
useFidoAgent: true,
};
assert.equal(shouldOfferAgentForLogin(inlineSk, { agent: {} }), true);
assert.equal(shouldOfferAgentForLogin({
authMethod: "password",
useSshAgent: false,
useFidoAgent: true,
}, { agent: {} }), false);
});
test("strict agent selection excludes unlocked default keys", () => {
const unlocked = [{ keyName: "id_other", privateKey: "PRIVATE KEY" }];
assert.deepEqual(resolveUnlockedEncryptedKeysForAuth({
+79 -8
View File
@@ -31,6 +31,7 @@ const {
buildRemoteShellExitPayload,
normalizeRemoteExitSignal,
} = require("./remoteShellExit.cjs");
const { looksLikeSkOpenSshMaterial } = require("../sshAuthHelper.cjs");
const SSH_TCP_CONNECT_TIMEOUT_MS = 20000;
const SSH_AUTH_READY_TIMEOUT_MS = 120000;
@@ -170,6 +171,9 @@ function hasSelectedAgentIdentity(options) {
function shouldOfferAgentForLogin(options, connectOpts) {
const selectedMethod = options?.authMethod;
// Path/inline sk-* detection may set useFidoAgent after prepare; honor it for
// login auth even when the renderer still sent useSshAgent:false.
const forceFidoAgent = options?.useFidoAgent === true && selectedMethod !== "password";
const hasRestrictedSelectedAgent = selectedMethod === "key"
&& options?.useSshAgent === true
&& options?.identitiesOnly === true
@@ -177,8 +181,8 @@ function shouldOfferAgentForLogin(options, connectOpts) {
const isStrictMethod = selectedMethod === "password"
|| selectedMethod === "key"
|| selectedMethod === "certificate";
return (!isStrictMethod || hasRestrictedSelectedAgent)
&& options?.useSshAgent !== false
return (!isStrictMethod || hasRestrictedSelectedAgent || forceFidoAgent)
&& (options?.useSshAgent !== false || forceFidoAgent)
&& Boolean(connectOpts?.agent);
}
@@ -1610,8 +1614,69 @@ function createStartSessionApi(ctx) {
});
let authAgent = null;
const systemAuthAgent = shouldPrepareSystemAgentForLogin(options)
? await prepareSystemSshAgentForAuth(options, "[SSH]")
// FIDO2 sk-* handles cannot be used as ssh2 software privateKeys.
// Detect only from key material (public/private text), not path names —
// path heuristics false-positive soft keys like id_mask.
let isFidoSkAuth = looksLikeSkOpenSshMaterial(options.privateKey)
|| (Array.isArray(options.agentPublicKeys)
&& options.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key)))
|| options.useFidoAgent === true;
// Path-only IdentityFile / reference keys may omit inline material and
// still be sk-* on disk — peek before choosing software-key auth.
if (!isFidoSkAuth && options.authMethod !== "password"
&& Array.isArray(options.identityFilePaths)
&& options.identityFilePaths.length > 0) {
try {
const { identityFilesLookLikeSk } = require("../sshAuthHelper.cjs");
isFidoSkAuth = await identityFilesLookLikeSk(options.identityFilePaths);
} catch {
// ignore probe failures; fall through to soft-key path
}
}
const forceSystemAgentForFido = isFidoSkAuth && options.authMethod !== "password";
// Propagate detected FIDO state onto options so shouldOfferAgentForLogin
// (and later auth decisions) see the same agent intent as preparation.
// Path-only IdentityFile SK picks often arrive with useSshAgent:false.
if (forceSystemAgentForFido) {
options.useSshAgent = true;
options.useFidoAgent = true;
options.identitiesOnly = options.identitiesOnly === true
|| Boolean(
(Array.isArray(options.agentPublicKeys) && options.agentPublicKeys.length)
|| (Array.isArray(options.identityFilePaths) && options.identityFilePaths.length),
);
options.addKeysToAgent = options.addKeysToAgent || "yes";
options.loadIdentityFilesIntoAgent = true;
}
const systemAuthAgent = (shouldPrepareSystemAgentForLogin(options) || forceSystemAgentForFido)
? await prepareSystemSshAgentForAuth({
...options,
// Only force the agent path when the caller already opted in or the
// key material requires hardware signing (FIDO). Automatic/password
// modes reach here via shouldPrepareSystemAgentForLogin's opportunistic
// check too; forcing useSshAgent:true unconditionally would make a
// missing system agent a hard failure for every "auto" connection
// instead of the no-op prepareSystemSshAgentForAuth already provides
// via its own useSshAgent !== true guard.
useSshAgent: options.useSshAgent === true || forceSystemAgentForFido,
useFidoAgent: forceSystemAgentForFido || options.useFidoAgent === true,
// Prefer loading the sk handle; keep identitiesOnly when a public
// key selector is available.
identitiesOnly: options.identitiesOnly === true
|| (forceSystemAgentForFido && Boolean(
(Array.isArray(options.agentPublicKeys) && options.agentPublicKeys.length)
|| (Array.isArray(options.identityFilePaths) && options.identityFilePaths.length),
)),
addKeysToAgent: options.addKeysToAgent || (forceSystemAgentForFido ? "yes" : options.addKeysToAgent),
loadIdentityFilesIntoAgent: forceSystemAgentForFido || options.loadIdentityFilesIntoAgent,
resolveWebContents: () => {
try {
return sender && !sender.isDestroyed?.() ? sender : null;
} catch {
return null;
}
},
}, "[SSH]")
: null;
// Kick off the default-key scan now so it overlaps the identity-file /
// inline-key preparation below instead of running serially after it.
@@ -1643,7 +1708,10 @@ function createStartSessionApi(ctx) {
},
})
: null;
const inlineKey = options.authMethod !== "password" && options.privateKey && !systemAuthAgent
const inlineKey = options.authMethod !== "password"
&& options.privateKey
&& !systemAuthAgent
&& !looksLikeSkOpenSshMaterial(options.privateKey)
? await preparePrivateKeyForAuth({
sender,
privateKey: options.privateKey,
@@ -1668,9 +1736,12 @@ function createStartSessionApi(ctx) {
if (systemAuthAgent) {
connectOpts.agent = systemAuthAgent;
require("../attachFidoAgentRelease.cjs").attachFidoAgentRelease(conn, systemAuthAgent);
}
if (hasCertificate) {
// Soft-key certificates use NetcattyAgent local signing. FIDO SK + cert
// must keep the system/owned agent (hardware signs via ssh-sk-helper).
if (hasCertificate && !forceSystemAgentForFido) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
@@ -1682,7 +1753,7 @@ function createStartSessionApi(ctx) {
},
});
connectOpts.agent = authAgent;
} else if (effectivePrivateKey) {
} else if (effectivePrivateKey && !forceSystemAgentForFido) {
connectOpts.privateKey = effectivePrivateKey;
if (effectiveIdentityPassphrase) {
connectOpts.passphrase = effectiveIdentityPassphrase;
@@ -2495,7 +2566,7 @@ function createStartSessionApi(ctx) {
conn.on("error", (err) => {
// ssh2 reports agent failures before trying the next auth method.
// Keep the connection and its authentication deadline alive.
if (err.level === "agent") {
if (err.level === "agent" && err.code !== "ERR_FIDO_CANCELLED") {
log("non-fatal agent auth error (will try next method)", {
sessionId,
hostname: options.hostname,
+331 -10
View File
@@ -21,6 +21,80 @@ function publicKeyBlob(key) {
}
}
/**
* Shared (e.g. Windows system-pipe) agent identity holds. Concurrent sessions
* that materialize the same SK key get unique temp paths, so cleanup must be
* keyed by public identity blob and only `ssh-add -d` when the last user leaves.
* @type {Map<string, { refCount: number, identityPath: string, cleanupDir: string|null }>}
*/
const sharedAgentIdentityRefs = new Map();
/**
* @param {string} key Public identity blob (or path fallback)
* @param {string} identityPath Path suitable for `ssh-add -d`
* @param {string|null} [cleanupDir] Temp dir to remove with the last release
* @returns {{ entry: { refCount: number, identityPath: string, cleanupDir: string|null }, acceptedCleanup: boolean }|null}
* `acceptedCleanup` is true only when this call recorded `cleanupDir` on a new
* map entry. Reuses increment refcount but do not adopt a second staging dir.
*/
function retainSharedAgentIdentity(key, identityPath, cleanupDir = null) {
if (typeof key !== "string" || !key || typeof identityPath !== "string" || !identityPath) {
return null;
}
const existing = sharedAgentIdentityRefs.get(key);
if (existing) {
existing.refCount += 1;
// First retainer owns identityPath + cleanupDir for ssh-add -d / rm.
return { entry: existing, acceptedCleanup: false };
}
const normalizedCleanup = typeof cleanupDir === "string" && cleanupDir ? cleanupDir : null;
const entry = {
refCount: 1,
identityPath,
cleanupDir: normalizedCleanup,
};
sharedAgentIdentityRefs.set(key, entry);
return { entry, acceptedCleanup: Boolean(normalizedCleanup) };
}
/**
* @param {string} key
* @returns {{ shouldRemove: boolean, identityPath: string|null, cleanupDir: string|null }}
*/
function releaseSharedAgentIdentity(key) {
if (typeof key !== "string" || !key) {
return { shouldRemove: false, identityPath: null, cleanupDir: null };
}
const existing = sharedAgentIdentityRefs.get(key);
if (!existing) return { shouldRemove: false, identityPath: null, cleanupDir: null };
existing.refCount -= 1;
if (existing.refCount > 0) {
return {
shouldRemove: false,
identityPath: existing.identityPath,
cleanupDir: existing.cleanupDir,
};
}
sharedAgentIdentityRefs.delete(key);
return {
shouldRemove: true,
identityPath: existing.identityPath,
cleanupDir: existing.cleanupDir,
};
}
/**
* @param {string} key
* @returns {boolean}
*/
function hasSharedAgentIdentity(key) {
return typeof key === "string" && key.length > 0 && sharedAgentIdentityRefs.has(key);
}
function resetSharedAgentIdentityRefsForTests() {
sharedAgentIdentityRefs.clear();
}
function resolveIdentityPath(rawPath, context = {}) {
if (typeof rawPath !== "string") return "";
const env = context.env ?? process.env;
@@ -143,18 +217,112 @@ function shouldLoadFromMacKeychain(options, platform) {
&& options.identityFilePaths.length > 0;
}
async function defaultRunSshAdd(args, { socketPath, env }) {
await execFileAsync("/usr/bin/ssh-add", args, {
timeout: 5000,
function resolveSshAddBinary(platform, env = process.env) {
if (typeof env.NETCATTY_SSH_ADD_PATH === "string" && env.NETCATTY_SSH_ADD_PATH.trim()) {
return env.NETCATTY_SSH_ADD_PATH.trim();
}
// Pair with Homebrew OpenSSH when present (same stack as ssh-agent / ssh-keygen).
if (platform === "darwin") {
for (const candidate of [
"/opt/homebrew/bin/ssh-add",
"/usr/local/bin/ssh-add",
"/usr/bin/ssh-add",
]) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// continue
}
}
}
if (platform === "win32") {
// Pair with Git/MSYS OpenSSH when present (same stack as user-mode ssh-agent).
const programFiles = env.ProgramFiles || "C:\\Program Files";
const programFilesX86 = env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
const localAppData = env.LOCALAPPDATA || "";
const userProfile = env.USERPROFILE || "";
for (const candidate of [
path.join(programFiles, "Git", "usr", "bin", "ssh-add.exe"),
path.join(programFilesX86, "Git", "usr", "bin", "ssh-add.exe"),
path.join(localAppData, "Programs", "Git", "usr", "bin", "ssh-add.exe"),
path.join(userProfile, "scoop", "apps", "git", "current", "usr", "bin", "ssh-add.exe"),
]) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// continue
}
}
}
return "ssh-add";
}
async function defaultRunSshAdd(args, { socketPath, env, platform, askpassEnv }) {
const sshAdd = resolveSshAddBinary(platform, env);
const mergedEnv = {
...env,
SSH_AUTH_SOCK: socketPath,
// Prefer a GUI askpass when provided (PIN / touch prompts). Without a TTY
// OpenSSH falls back to SSH_ASKPASS for ssh-sk-helper.
...(askpassEnv || { SSH_ASKPASS_REQUIRE: "never" }),
};
// OpenSSH only invokes askpass when stdin is not a TTY; force that here.
await execFileAsync(sshAdd, args, {
// Align with FIDO prompt TTL (~180s) so the modal is not left open after failure.
timeout: 170000,
windowsHide: true,
env: {
...env,
SSH_AUTH_SOCK: socketPath,
SSH_ASKPASS_REQUIRE: "never",
},
env: mergedEnv,
// Detach from parent's TTY so SSH_ASKPASS_REQUIRE=force is honored.
stdio: ["ignore", "pipe", "pipe"],
});
}
const OPENSSH_PRIVATE_KEY_RE =
/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END OPENSSH PRIVATE KEY-----/;
const SK_SSH_ED25519 = "sk-ssh-ed25519@openssh.com";
const SK_ECDSA_NISTP256 = "sk-ecdsa-sha2-nistp256@openssh.com";
function looksLikeSkPublicKeyText(text) {
return typeof text === "string"
&& /sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)(?:-cert-v0[01])?@openssh\.com/.test(text);
}
/** True for sk public lines *or* sk private PEMs (type may only exist after base64 decode). */
function looksLikeSkKeyMaterial(text) {
if (typeof text !== "string" || !text.trim()) return false;
if (looksLikeSkPublicKeyText(text)) return true;
const match = OPENSSH_PRIVATE_KEY_RE.exec(text);
if (!match) return false;
try {
const body = Buffer.from(match[1].replace(/\s+/g, ""), "base64").toString("binary");
return body.includes(SK_SSH_ED25519) || body.includes(SK_ECDSA_NISTP256);
} catch {
return false;
}
}
async function shouldLoadIdentityFileIntoAgent(identityPath, options, deps) {
// Explicit opt-in (used for vault-imported FIDO handles).
if (options.loadIdentityFilesIntoAgent === true) return true;
// Soft keys continue to use macOS Keychain / user-managed agent flows.
// Auto-load only FIDO2 sk-* identity files so ssh2 can request hardware
// signatures without changing AddKeysToAgent policy for regular keys.
try {
const pubPath = identityPath.endsWith(".pub") ? identityPath : `${identityPath}.pub`;
const pub = await deps.readFile(pubPath, "utf8");
if (looksLikeSkKeyMaterial(pub)) return true;
} catch {
// fall through to private-key probe
}
try {
const privateKey = await deps.readFile(identityPath, "utf8");
if (looksLikeSkKeyMaterial(privateKey)) return true;
} catch {
return false;
}
return false;
}
async function prepareSystemSshAgent(options, injected = {}) {
if (!options?.socketPath) return null;
const deps = {
@@ -164,6 +332,7 @@ async function prepareSystemSshAgent(options, injected = {}) {
platform: injected.platform ?? process.platform,
env: injected.env ?? process.env,
log: injected.log,
askpassEnv: injected.askpassEnv,
};
const agent = deps.createAgent(options.socketPath);
const { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths } = await loadPreferredPublicKeyBlobs(
@@ -192,7 +361,14 @@ async function prepareSystemSshAgent(options, injected = {}) {
try {
const args = ["--apple-load-keychain", ...resolvedIdentityPaths];
if (deps.runSshAdd) await deps.runSshAdd(args);
else await defaultRunSshAdd(args, { socketPath: options.socketPath, env: deps.env });
else {
await defaultRunSshAdd(args, {
socketPath: options.socketPath,
env: deps.env,
platform: deps.platform,
askpassEnv: deps.askpassEnv,
});
}
} catch (error) {
deps.log?.("macOS Keychain could not load the configured SSH identity", {
error: error instanceof Error ? error.message : String(error),
@@ -201,10 +377,142 @@ async function prepareSystemSshAgent(options, injected = {}) {
}
}
// Load FIDO2 / SK identity files (and other identities when addKeysToAgent is
// set) into the agent so ssh2 can request hardware signatures.
/** @type {string[]} Paths newly ssh-add'd this preparation (for shared-agent cleanup). */
const newlyLoadedIdentityPaths = [];
/** @type {Array<{ key: string, identityPath: string }>} Identities this prep depends on. */
const sharedAgentIdentities = [];
if (resolvedIdentityPaths.length > 0) {
let loadedBlobs = new Set();
try {
loadedBlobs = new Set((await getIdentities(agent)).map(publicKeyBlob).filter(Boolean));
} catch (error) {
deps.log?.("Could not inspect SSH agent identities before identity load", {
error: error instanceof Error ? error.message : String(error),
});
}
for (const identityPath of resolvedIdentityPaths) {
const pubPath = identityPath.endsWith(".pub") ? identityPath : `${identityPath}.pub`;
const certPath = identityPath.endsWith(".pub") ? null : `${identityPath}-cert.pub`;
/** @type {string|null} */
let blob = null;
/** @type {string|null} */
let certBlob = null;
let bareAlreadyPresent = false;
let companionCertificateMissing = false;
try {
const pub = await deps.readFile(pubPath, "utf8");
blob = publicKeyBlob(pub);
if (blob && loadedBlobs.has(blob)) bareAlreadyPresent = true;
} catch {
// No .pub selector — still attempt ssh-add when requested.
}
if (bareAlreadyPresent && certPath) {
// OpenSSH ssh-add loads `<identity>-cert.pub` with the private handle.
// If only the bare key is already advertised, still run ssh-add so a
// staged vault certificate reaches the agent.
try {
const certPub = await deps.readFile(certPath, "utf8");
certBlob = publicKeyBlob(certPub);
if (certBlob && !loadedBlobs.has(certBlob)) {
companionCertificateMissing = true;
}
} catch {
// No companion certificate — bare-key skip remains valid.
}
}
if (bareAlreadyPresent && !companionCertificateMissing) {
// Join refcount only when Netcatty already owns this identity (another
// session loaded it). Pre-existing user agent identities must not be
// adopted into cleanup — last release would ssh-add -d them.
const key = blob || identityPath;
if (hasSharedAgentIdentity(key)) {
sharedAgentIdentities.push({ key, identityPath });
}
continue;
}
if (!(await shouldLoadIdentityFileIntoAgent(identityPath, options, deps))) continue;
try {
const args = [identityPath];
if (deps.runSshAdd) await deps.runSshAdd(args);
else {
await defaultRunSshAdd(args, {
socketPath: options.socketPath,
env: deps.env,
platform: deps.platform,
askpassEnv: deps.askpassEnv,
});
}
if (!blob) {
try {
const pub = await deps.readFile(pubPath, "utf8");
blob = publicKeyBlob(pub);
} catch {
// path-keyed fallback below
}
}
if (blob) loadedBlobs.add(blob);
if (certBlob) loadedBlobs.add(certBlob);
else if (certPath) {
try {
const certPub = await deps.readFile(certPath, "utf8");
certBlob = publicKeyBlob(certPub);
if (certBlob) loadedBlobs.add(certBlob);
} catch {
// no companion cert
}
}
const key = blob || identityPath;
if (bareAlreadyPresent) {
// Reloaded only to advertise a missing companion certificate.
if (hasSharedAgentIdentity(key)) {
// Another Netcatty session already owns the bare identity; join its
// refcount so the last release can ssh-add -d the private handle.
sharedAgentIdentities.push({ key, identityPath });
} else if (certBlob && certPath) {
// Bare key was already in the shared agent (user-managed). Track the
// certificate alone so cleanup removes only what we added.
newlyLoadedIdentityPaths.push(certPath);
sharedAgentIdentities.push({ key: certBlob, identityPath: certPath });
}
} else {
newlyLoadedIdentityPaths.push(identityPath);
sharedAgentIdentities.push({ key, identityPath });
}
deps.log?.("Loaded identity into SSH agent", { identityPath });
} catch (error) {
deps.log?.("Could not load identity into SSH agent", {
identityPath,
error: error instanceof Error ? error.message : String(error),
});
}
}
}
if (
options.identitiesOnly === true
&& (preferred.size === 0 || (unavailablePublicKeyPaths.length > 0 && providedPreferredCount === 0))
) {
// Roll back identities we just added so a failed prep does not leak keys
// into a shared (e.g. Windows system) agent.
for (const identityPath of newlyLoadedIdentityPaths) {
try {
const args = ["-d", identityPath];
if (deps.runSshAdd) await deps.runSshAdd(args);
else {
await defaultRunSshAdd(args, {
socketPath: options.socketPath,
env: deps.env,
platform: deps.platform,
askpassEnv: { SSH_ASKPASS_REQUIRE: "never" },
});
}
} catch {
// ignore
}
}
const error = new Error(
unavailablePublicKeyPaths.length > 0
? `IdentitiesOnly requires a readable public key selector. Missing or invalid: ${unavailablePublicKeyPaths.join(", ")}`
@@ -214,7 +522,14 @@ async function prepareSystemSshAgent(options, injected = {}) {
throw error;
}
return new IdentityAwareAgent(agent, preferred, options.identitiesOnly === true);
const wrapped = new IdentityAwareAgent(agent, preferred, options.identitiesOnly === true);
if (newlyLoadedIdentityPaths.length > 0) {
wrapped._netcattyNewlyLoadedIdentityPaths = newlyLoadedIdentityPaths;
}
if (sharedAgentIdentities.length > 0) {
wrapped._netcattySharedAgentIdentities = sharedAgentIdentities;
}
return wrapped;
}
module.exports = {
@@ -222,5 +537,11 @@ module.exports = {
prepareSystemSshAgent,
publicKeyBlob,
resolveIdentityPath,
resolveSshAddBinary,
shouldLoadFromMacKeychain,
shouldLoadIdentityFileIntoAgent,
retainSharedAgentIdentity,
releaseSharedAgentIdentity,
hasSharedAgentIdentity,
resetSharedAgentIdentityRefsForTests,
};
@@ -0,0 +1,470 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
shouldLoadIdentityFileIntoAgent,
publicKeyBlob,
} = require("./systemSshAgent.cjs");
const { parseKey } = require("ssh2/lib/protocol/keyParser.js");
const SK_SSH_ED25519 = "sk-ssh-ed25519@openssh.com";
function makeSkPrivatePem() {
const body = Buffer.from(`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${SK_SSH_ED25519}`).toString("base64");
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
assert.equal(pem.includes("@openssh.com"), false);
return pem;
}
test("shouldLoadIdentityFileIntoAgent loads sk public keys", async () => {
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
const pub = Buffer.alloc(32, 3);
const app = Buffer.from("ssh:");
const parts = [type, pub, app];
let len = 0;
for (const part of parts) len += 4 + part.length;
const buf = Buffer.alloc(len);
let offset = 0;
for (const part of parts) {
buf.writeUInt32BE(part.length, offset);
offset += 4;
part.copy(buf, offset);
offset += part.length;
}
const pubLine = `sk-ssh-ed25519@openssh.com ${buf.toString("base64")} test`;
const files = {
"/tmp/id_ed25519_sk.pub": pubLine,
};
const deps = {
readFile: async (p) => {
if (files[p] !== undefined) return files[p];
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
},
};
assert.equal(
await shouldLoadIdentityFileIntoAgent("/tmp/id_ed25519_sk", {}, deps),
true,
);
assert.equal(
await shouldLoadIdentityFileIntoAgent("/tmp/id_ed25519_sk", { addKeysToAgent: "yes" }, deps),
true,
);
assert.equal(
await shouldLoadIdentityFileIntoAgent(
"/tmp/soft",
{},
{
readFile: async () => "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJust soft",
},
),
false,
);
const parsed = parseKey(pubLine);
assert.equal(parsed instanceof Error, false);
assert.ok(publicKeyBlob(parsed));
});
test("shouldLoadIdentityFileIntoAgent detects base64-only sk private PEMs", async () => {
const pem = makeSkPrivatePem();
const deps = {
readFile: async (p) => {
if (p === "/tmp/id_ed25519_sk" || p.endsWith("/id_ed25519_sk")) return pem;
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
},
};
assert.equal(
await shouldLoadIdentityFileIntoAgent("/tmp/id_ed25519_sk", {}, deps),
true,
);
});
test("prepareSystemSshAgent tracks newly loaded identities for shared-agent cleanup", async () => {
const { prepareSystemSshAgent } = require("./systemSshAgent.cjs");
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
const pub = Buffer.alloc(32, 3);
const app = Buffer.from("ssh:");
const parts = [type, pub, app];
let len = 0;
for (const part of parts) len += 4 + part.length;
const buf = Buffer.alloc(len);
let offset = 0;
for (const part of parts) {
buf.writeUInt32BE(part.length, offset);
offset += 4;
part.copy(buf, offset);
offset += part.length;
}
const pubLine = `sk-ssh-ed25519@openssh.com ${buf.toString("base64")} test`;
const identityPath = "/tmp/id_ed25519_sk_new";
const sshAddCalls = [];
const fakeAgent = {
getIdentities: (cb) => cb(null, []),
sign: (_key, _data, _opts, cb) => cb(new Error("unused")),
};
const prepared = await prepareSystemSshAgent({
socketPath: "/tmp/fake-agent.sock",
identityFilePaths: [identityPath],
loadIdentityFilesIntoAgent: true,
}, {
createAgent: () => fakeAgent,
readFile: async (p) => {
if (p === `${identityPath}.pub`) return pubLine;
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
},
runSshAdd: async (args) => { sshAddCalls.push(args); },
platform: "win32",
});
assert.deepEqual(sshAddCalls, [[identityPath]]);
assert.deepEqual(prepared._netcattyNewlyLoadedIdentityPaths, [identityPath]);
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
{ key: publicKeyBlob(pubLine), identityPath },
]);
});
test("prepareSystemSshAgent does not adopt pre-existing agent identities for cleanup", async () => {
const {
prepareSystemSshAgent,
publicKeyBlob,
resetSharedAgentIdentityRefsForTests,
} = require("./systemSshAgent.cjs");
resetSharedAgentIdentityRefsForTests();
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
const pub = Buffer.alloc(32, 7);
const app = Buffer.from("ssh:");
const parts = [type, pub, app];
let len = 0;
for (const part of parts) len += 4 + part.length;
const buf = Buffer.alloc(len);
let offset = 0;
for (const part of parts) {
buf.writeUInt32BE(part.length, offset);
offset += 4;
part.copy(buf, offset);
offset += part.length;
}
const pubLine = `sk-ssh-ed25519@openssh.com ${buf.toString("base64")} test`;
const identityPath = "/tmp/id_ed25519_sk_existing";
const blob = publicKeyBlob(pubLine);
const sshAddCalls = [];
const fakeKey = {
getPublicSSH: () => Buffer.from(blob, "base64"),
};
const fakeAgent = {
getIdentities: (cb) => cb(null, [fakeKey]),
sign: (_key, _data, _opts, cb) => cb(new Error("unused")),
};
try {
const prepared = await prepareSystemSshAgent({
socketPath: "/tmp/fake-agent.sock",
identityFilePaths: [identityPath],
loadIdentityFilesIntoAgent: true,
}, {
createAgent: () => fakeAgent,
readFile: async (p) => {
if (p === `${identityPath}.pub`) return pubLine;
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
},
runSshAdd: async (args) => { sshAddCalls.push(args); },
platform: "win32",
});
assert.deepEqual(sshAddCalls, []);
assert.equal(prepared._netcattyNewlyLoadedIdentityPaths, undefined);
assert.equal(prepared._netcattySharedAgentIdentities, undefined);
} finally {
resetSharedAgentIdentityRefsForTests();
}
});
test("prepareSystemSshAgent ssh-adds when bare identity is loaded but companion certificate is missing", async () => {
const {
prepareSystemSshAgent,
publicKeyBlob,
retainSharedAgentIdentity,
resetSharedAgentIdentityRefsForTests,
} = require("./systemSshAgent.cjs");
resetSharedAgentIdentityRefsForTests();
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
const pub = Buffer.alloc(32, 11);
const app = Buffer.from("ssh:");
const parts = [type, pub, app];
let len = 0;
for (const part of parts) len += 4 + part.length;
const buf = Buffer.alloc(len);
let offset = 0;
for (const part of parts) {
buf.writeUInt32BE(part.length, offset);
offset += 4;
part.copy(buf, offset);
offset += part.length;
}
const pubLine = `sk-ssh-ed25519@openssh.com ${buf.toString("base64")} test`;
const certType = Buffer.from("sk-ssh-ed25519-cert-v01@openssh.com");
const nonce = Buffer.alloc(16, 3);
const certParts = [certType, nonce, pub, app];
let certLen = 0;
for (const part of certParts) certLen += 4 + part.length;
const certBuf = Buffer.alloc(certLen);
let certOffset = 0;
for (const part of certParts) {
certBuf.writeUInt32BE(part.length, certOffset);
certOffset += 4;
part.copy(certBuf, certOffset);
certOffset += part.length;
}
const certLine = `sk-ssh-ed25519-cert-v01@openssh.com ${certBuf.toString("base64")} cert`;
const identityPath = "/tmp/id_ed25519_sk_cert_reload";
const certPath = `${identityPath}-cert.pub`;
const blob = publicKeyBlob(pubLine);
const certBlob = publicKeyBlob(certLine);
assert.ok(blob);
assert.ok(certBlob);
assert.notEqual(blob, certBlob);
const sshAddCalls = [];
const fakeKey = {
getPublicSSH: () => Buffer.from(blob, "base64"),
};
const fakeAgent = {
getIdentities: (cb) => cb(null, [fakeKey]),
sign: (_key, _data, _opts, cb) => cb(new Error("unused")),
};
try {
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first");
const prepared = await prepareSystemSshAgent({
socketPath: "/tmp/fake-agent.sock",
identityFilePaths: [identityPath],
loadIdentityFilesIntoAgent: true,
}, {
createAgent: () => fakeAgent,
readFile: async (p) => {
if (p === `${identityPath}.pub`) return pubLine;
if (p === certPath) return certLine;
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
},
runSshAdd: async (args) => { sshAddCalls.push(args); },
platform: "win32",
});
assert.deepEqual(sshAddCalls, [[identityPath]]);
// Bare key was already Netcatty-owned; join that refcount (do not treat
// the cert-only reload as a newly loaded private identity).
assert.equal(prepared._netcattyNewlyLoadedIdentityPaths, undefined);
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
{ key: blob, identityPath },
]);
} finally {
resetSharedAgentIdentityRefsForTests();
}
});
test("prepareSystemSshAgent tracks companion cert added beside a pre-existing user bare key", async () => {
const {
prepareSystemSshAgent,
publicKeyBlob,
resetSharedAgentIdentityRefsForTests,
} = require("./systemSshAgent.cjs");
resetSharedAgentIdentityRefsForTests();
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
const pub = Buffer.alloc(32, 13);
const app = Buffer.from("ssh:");
const parts = [type, pub, app];
let len = 0;
for (const part of parts) len += 4 + part.length;
const buf = Buffer.alloc(len);
let offset = 0;
for (const part of parts) {
buf.writeUInt32BE(part.length, offset);
offset += 4;
part.copy(buf, offset);
offset += part.length;
}
const pubLine = `sk-ssh-ed25519@openssh.com ${buf.toString("base64")} test`;
const certType = Buffer.from("sk-ssh-ed25519-cert-v01@openssh.com");
const nonce = Buffer.alloc(16, 5);
const certParts = [certType, nonce, pub, app];
let certLen = 0;
for (const part of certParts) certLen += 4 + part.length;
const certBuf = Buffer.alloc(certLen);
let certOffset = 0;
for (const part of certParts) {
certBuf.writeUInt32BE(part.length, certOffset);
certOffset += 4;
part.copy(certBuf, certOffset);
certOffset += part.length;
}
const certLine = `sk-ssh-ed25519-cert-v01@openssh.com ${certBuf.toString("base64")} cert`;
const identityPath = "/tmp/id_ed25519_sk_user_bare";
const certPath = `${identityPath}-cert.pub`;
const blob = publicKeyBlob(pubLine);
const certBlob = publicKeyBlob(certLine);
assert.ok(blob);
assert.ok(certBlob);
const sshAddCalls = [];
const fakeKey = {
getPublicSSH: () => Buffer.from(blob, "base64"),
};
const fakeAgent = {
getIdentities: (cb) => cb(null, [fakeKey]),
sign: (_key, _data, _opts, cb) => cb(new Error("unused")),
};
try {
const prepared = await prepareSystemSshAgent({
socketPath: "/tmp/fake-agent.sock",
identityFilePaths: [identityPath],
loadIdentityFilesIntoAgent: true,
}, {
createAgent: () => fakeAgent,
readFile: async (p) => {
if (p === `${identityPath}.pub`) return pubLine;
if (p === certPath) return certLine;
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
},
runSshAdd: async (args) => { sshAddCalls.push(args); },
platform: "win32",
});
assert.deepEqual(sshAddCalls, [[identityPath]]);
// Do not adopt the user's bare identity; track only the certificate we added.
assert.deepEqual(prepared._netcattyNewlyLoadedIdentityPaths, [certPath]);
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
{ key: certBlob, identityPath: certPath },
]);
} finally {
resetSharedAgentIdentityRefsForTests();
}
});
test("prepareSystemSshAgent joins Netcatty-owned already-loaded identities for shared cleanup", async () => {
const {
prepareSystemSshAgent,
publicKeyBlob,
retainSharedAgentIdentity,
resetSharedAgentIdentityRefsForTests,
} = require("./systemSshAgent.cjs");
resetSharedAgentIdentityRefsForTests();
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
const pub = Buffer.alloc(32, 9);
const app = Buffer.from("ssh:");
const parts = [type, pub, app];
let len = 0;
for (const part of parts) len += 4 + part.length;
const buf = Buffer.alloc(len);
let offset = 0;
for (const part of parts) {
buf.writeUInt32BE(part.length, offset);
offset += 4;
part.copy(buf, offset);
offset += part.length;
}
const pubLine = `sk-ssh-ed25519@openssh.com ${buf.toString("base64")} test`;
const identityPath = "/tmp/id_ed25519_sk_owned";
const blob = publicKeyBlob(pubLine);
const fakeKey = {
getPublicSSH: () => Buffer.from(blob, "base64"),
};
const fakeAgent = {
getIdentities: (cb) => cb(null, [fakeKey]),
sign: (_key, _data, _opts, cb) => cb(new Error("unused")),
};
try {
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first");
const prepared = await prepareSystemSshAgent({
socketPath: "/tmp/fake-agent.sock",
identityFilePaths: [identityPath],
loadIdentityFilesIntoAgent: true,
}, {
createAgent: () => fakeAgent,
readFile: async (p) => {
if (p === `${identityPath}.pub`) return pubLine;
throw Object.assign(new Error("ENOENT"), { code: "ENOENT" });
},
runSshAdd: async () => {
throw new Error("ssh-add must not run for already-loaded owned identity");
},
platform: "win32",
});
assert.equal(prepared._netcattyNewlyLoadedIdentityPaths, undefined);
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
{ key: blob, identityPath },
]);
} finally {
resetSharedAgentIdentityRefsForTests();
}
});
test("retainSharedAgentIdentity reference-counts identical public identities", () => {
const {
retainSharedAgentIdentity,
releaseSharedAgentIdentity,
resetSharedAgentIdentityRefsForTests,
} = require("./systemSshAgent.cjs");
resetSharedAgentIdentityRefsForTests();
try {
const first = retainSharedAgentIdentity("blob-1", "/tmp/a");
const second = retainSharedAgentIdentity("blob-1", "/tmp/b");
assert.equal(first?.acceptedCleanup, false);
assert.equal(second?.acceptedCleanup, false);
assert.deepEqual(releaseSharedAgentIdentity("blob-1"), {
shouldRemove: false,
identityPath: "/tmp/a",
cleanupDir: null,
});
assert.deepEqual(releaseSharedAgentIdentity("blob-1"), {
shouldRemove: true,
identityPath: "/tmp/a",
cleanupDir: null,
});
} finally {
resetSharedAgentIdentityRefsForTests();
}
});
test("retainSharedAgentIdentity adopts cleanupDir only on the first retainer", () => {
const {
retainSharedAgentIdentity,
releaseSharedAgentIdentity,
resetSharedAgentIdentityRefsForTests,
} = require("./systemSshAgent.cjs");
resetSharedAgentIdentityRefsForTests();
try {
const first = retainSharedAgentIdentity("blob-1", "/tmp/a/key", "/tmp/a");
const second = retainSharedAgentIdentity("blob-1", "/tmp/b/key", "/tmp/b");
assert.equal(first?.acceptedCleanup, true);
assert.equal(first?.entry.cleanupDir, "/tmp/a");
assert.equal(second?.acceptedCleanup, false);
assert.equal(second?.entry.cleanupDir, "/tmp/a");
assert.deepEqual(releaseSharedAgentIdentity("blob-1"), {
shouldRemove: false,
identityPath: "/tmp/a/key",
cleanupDir: "/tmp/a",
});
assert.deepEqual(releaseSharedAgentIdentity("blob-1"), {
shouldRemove: true,
identityPath: "/tmp/a/key",
cleanupDir: "/tmp/a",
});
} finally {
resetSharedAgentIdentityRefsForTests();
}
});
@@ -297,6 +297,37 @@ test("Mosh explicitly disables native agent login after an opt-out", async () =>
assert.equal(forwardingEnv.SSH_AUTH_SOCK, undefined);
});
test("Mosh releases prepared FIDO agent when forwarding-agent resolution fails", async () => {
let releaseCount = 0;
const api = createMoshSessionApi({
os,
path,
fs,
process,
randomUUID: () => "fixed",
prepareSystemSshAgentForAuth: async () => ({
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
}),
getAvailableAgentSocket: async () => "/tmp/login-agent.sock",
getAvailableForwardingAgentSocket: async () => {
const error = new Error(
"This SSH agent is available only to Netcatty's built-in SSH client.",
);
error.code = "ERR_SSH_AGENT_NATIVE_UNSUPPORTED";
throw error;
},
});
await assert.rejects(
() => api.prepareMoshSshAgentOptions({
useSshAgent: true,
agentForwarding: true,
}),
(error) => error?.code === "ERR_SSH_AGENT_NATIVE_UNSUPPORTED",
);
assert.equal(releaseCount, 1);
});
test("Mosh keeps its login agent separate from the discovered forwarding agent", async () => {
const localAgent = "/private/tmp/com.apple.launchd.test/Listeners";
const forwardingAgent = "/Users/alice/.bitwarden-ssh-agent.sock";
@@ -400,6 +431,44 @@ test("Mosh automatic mode discovers custom local keys in preferred order", async
]);
});
test("startMoshSessionViaHandshake releases FIDO lease when auth setup fails", async () => {
let releaseCount = 0;
const moshHandshake = require("./moshHandshake.cjs");
const api = createMoshSessionApi({
os,
path,
fs,
process,
randomUUID: () => "fixed",
moshHandshake,
pty: {
spawn() {
throw new Error("pty.spawn should not run after auth setup failure");
},
},
tempDirBridge: {
getTempFilePath() {
throw new Error("temp unavailable");
},
},
});
await assert.rejects(
() => api.startMoshSessionViaHandshake(
{ sender: { id: 1 } },
{
hostname: "host.example",
username: "alice",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----",
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
},
{ bareClient: "/tmp/mosh-client", sshExe: "/usr/bin/ssh" },
),
/temp unavailable/,
);
assert.equal(releaseCount, 1);
});
test("removed Mosh client detection APIs are not exposed to the renderer", () => {
const bridgeSource = fs.readFileSync(path.join(__dirname, "terminalBridge.cjs"), "utf8");
const preloadSource = fs.readFileSync(path.join(__dirname, "..", "preload.cjs"), "utf8");
+2
View File
@@ -2319,6 +2319,7 @@ function closeSession(event, payload) {
session.zmodemSentry?.cancel();
session.discardPendingData?.();
cleanupSessionExternalAuthArtifacts(session);
session.releaseNetcattyFidoAgents?.();
session.releaseTelnetGeneration?.();
if (session.stream) {
// Snapshot multiplexing state *before* closing the channel: closing the
@@ -2764,6 +2765,7 @@ function cleanupAllSessions() {
session.discardPendingData?.();
closeTerminalOutputSession(sessionId);
cleanupSessionExternalAuthArtifacts(session);
session.releaseNetcattyFidoAgents?.();
session.releaseTelnetGeneration?.();
if (session.stream) {
session.stream.close();
+123 -26
View File
@@ -159,39 +159,125 @@ main();
return raw.toLowerCase().endsWith(".pub") ? raw : `${raw}.pub`;
}
async function prepareEtSshAgentOptions(options) {
async function prepareEtSshAgentOptions(options, sender) {
const {
enhanceAuthOptionsForFido,
resolvePreparedAgentSocket,
} = require("../sshAuthHelper.cjs");
const prepareOne = async (connectionOptions, logPrefix) => {
if (connectionOptions?.useSshAgent !== true && !connectionOptions?.agentForwarding) return connectionOptions;
const prepOptions = await enhanceAuthOptionsForFido(connectionOptions, sender);
const forceFido = prepOptions.useFidoAgent === true
&& connectionOptions.authMethod !== "password";
if (connectionOptions?.useSshAgent !== true && !connectionOptions?.agentForwarding && !forceFido) {
return connectionOptions;
}
let prepared = connectionOptions;
if (connectionOptions.useSshAgent === true) {
await prepareSystemSshAgentForAuth(connectionOptions, logPrefix);
const loginSocketPath = await getAvailableAgentSocket(connectionOptions.identityAgent, connectionOptions);
if (!loginSocketPath) {
throw new Error("System SSH agent is unavailable. Start or unlock it, or configure a valid agent socket.");
/** @type {(() => void)|null} */
let releasePreparedAgent = null;
try {
if (connectionOptions.useSshAgent === true || forceFido) {
const agent = await prepareSystemSshAgentForAuth(prepOptions, logPrefix);
if (typeof agent?._releaseNetcattyFidoAgent === "function") {
releasePreparedAgent = agent._releaseNetcattyFidoAgent;
}
const socketPath = resolvePreparedAgentSocket(agent, prepOptions)
|| await getAvailableAgentSocket(connectionOptions.identityAgent, connectionOptions);
if (!socketPath) {
throw new Error(
forceFido
? "FIDO2 SSH agent is unavailable. Install OpenSSH with libfido2 and try again."
: "System SSH agent is unavailable. Start or unlock it, or configure a valid agent socket.",
);
}
prepared = {
...prepared,
useSshAgent: true,
_resolvedSshAgentSocket: socketPath,
...(releasePreparedAgent
? { _releaseNetcattyFidoAgent: releasePreparedAgent }
: {}),
};
}
prepared = { ...prepared, _resolvedSshAgentSocket: loginSocketPath };
}
if (connectionOptions.agentForwarding) {
const forwardingSocketPath = await getAvailableForwardingAgentSocket(
connectionOptions.identityAgent,
connectionOptions,
);
if (forwardingSocketPath) {
prepared = { ...prepared, _resolvedForwardingAgentSocket: forwardingSocketPath };
if (connectionOptions.agentForwarding) {
// May throw (e.g. Windows Pageant/Cygwin rejected for native OpenSSH).
// Release this hop's already-acquired FIDO agent/askpass lease before
// rethrowing — preparedEntries only sees hops that return successfully.
const forwardingSocketPath = await getAvailableForwardingAgentSocket(
connectionOptions.identityAgent,
connectionOptions,
);
if (forwardingSocketPath) {
prepared = { ...prepared, _resolvedForwardingAgentSocket: forwardingSocketPath };
}
}
return prepared;
} catch (error) {
if (typeof releasePreparedAgent === "function") {
try { releasePreparedAgent(); } catch { /* ignore */ }
}
throw error;
}
return prepared;
};
const preparedTarget = await prepareOne(options, "[ET]");
if (!Array.isArray(options.jumpHosts) || options.jumpHosts.length === 0) {
return preparedTarget;
// Track successfully prepared hops so a later prepareOne failure can release
// earlier FIDO agent/askpass leases (caller still has the no-op release).
const preparedEntries = [];
const releasePreparedEntries = () => {
const seen = new Set();
for (const entry of preparedEntries) {
const releaseFn = entry?._releaseNetcattyFidoAgent;
if (typeof releaseFn !== "function" || seen.has(releaseFn)) continue;
seen.add(releaseFn);
try { releaseFn(); } catch { /* ignore */ }
}
};
try {
const preparedTarget = await prepareOne(options, "[ET]");
preparedEntries.push(preparedTarget);
if (!Array.isArray(options.jumpHosts) || options.jumpHosts.length === 0) {
return preparedTarget;
}
const preparedJumpHosts = [];
for (let index = 0; index < options.jumpHosts.length; index += 1) {
const preparedJump = await prepareOne(
options.jumpHosts[index],
`[ET Chain] Hop ${index + 1}:`,
);
preparedEntries.push(preparedJump);
preparedJumpHosts.push(preparedJump);
}
return { ...preparedTarget, jumpHosts: preparedJumpHosts };
} catch (error) {
releasePreparedEntries();
throw error;
}
const preparedJumpHosts = [];
for (let index = 0; index < options.jumpHosts.length; index += 1) {
preparedJumpHosts.push(await prepareOne(options.jumpHosts[index], `[ET Chain] Hop ${index + 1}:`));
}
function collectEtFidoAgentReleases(options) {
const releases = [];
if (typeof options?._releaseNetcattyFidoAgent === "function") {
releases.push(options._releaseNetcattyFidoAgent);
}
return { ...preparedTarget, jumpHosts: preparedJumpHosts };
for (const jump of options?.jumpHosts || []) {
if (typeof jump?._releaseNetcattyFidoAgent === "function") {
releases.push(jump._releaseNetcattyFidoAgent);
}
}
return releases;
}
function createOneShotEtFidoAgentReleases(options) {
const pending = collectEtFidoAgentReleases(options);
if (pending.length === 0) return () => {};
const seen = new Set();
return () => {
for (const releaseFn of pending) {
if (seen.has(releaseFn)) continue;
seen.add(releaseFn);
try { releaseFn(); } catch { /* ignore */ }
}
};
}
function applyEtSshAgentEnvironment(env, options) {
@@ -1169,14 +1255,17 @@ main();
}
let sshEnvironment;
let releaseFidoAgents = () => {};
try {
const preparedOptions = await prepareEtSshAgentOptions(options);
const preparedOptions = await prepareEtSshAgentOptions(options, event?.sender);
releaseFidoAgents = createOneShotEtFidoAgentReleases(preparedOptions);
options = preparedOptions;
if (options.agentForwarding && options._resolvedForwardingAgentSocket) {
args.push("-f", "--ssh-socket", options._resolvedForwardingAgentSocket);
}
sshEnvironment = prepareEtSshEnvironment(sessionId, preparedOptions);
} catch (err) {
releaseFidoAgents();
throw new Error(err instanceof Error ? err.message : String(err));
}
@@ -1283,6 +1372,9 @@ main();
lastIdlePrompt: "",
lastIdlePromptAt: 0,
_promptTrackTail: "",
// Explicit close deletes the session before onExit; keep the one-shot
// release on the session so closeSession / quit cleanup can still run it.
releaseNetcattyFidoAgents: releaseFidoAgents,
};
{
const { claimSessionSlot } = require("../sessionBootEpoch.cjs");
@@ -1290,6 +1382,7 @@ main();
if (!claim.ok) {
try { proc.kill(); } catch { /* ignore */ }
cleanupSessionExternalAuthArtifacts(session);
releaseFidoAgents();
const supersededError = new Error("Connection superseded by a newer reconnect");
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
throw supersededError;
@@ -1375,8 +1468,11 @@ main();
proc.onExit((evt) => {
flushEtPaced(() => {
if (etExitFinalized) return;
if (sessions.get(sessionId) !== session) return;
etExitFinalized = true;
// Release before the map check: explicit close already deleted the
// session entry, but the agent/askpass lease must still be dropped.
releaseFidoAgents();
if (sessions.get(sessionId) !== session) return;
try { session.etStatsConn?.end(); } catch { /* ignore */ }
cleanupSessionExternalAuthArtifacts(session);
sessionLogStreamManager.stopStream(sessionId, session.logStreamToken);
@@ -1395,6 +1491,7 @@ main();
return { sessionId };
} catch (err) {
releaseFidoAgents();
if (sshEnvironment?.artifacts) {
cleanupSessionExternalAuthArtifacts({
externalAuthArtifacts: sshEnvironment.artifacts,
@@ -715,6 +715,122 @@ test("ET prepares target and jump agents before generating their host config", a
assert.equal(fs.readFileSync(jumpSelectorMatch[1], "utf8"), "ssh-ed25519 AAAAJUMPSELECTED");
});
test("prepareEtSshAgentOptions releases earlier FIDO leases when a later hop fails", async (t) => {
let releaseCount = 0;
const { api } = makeApi(t, {
prepareSystemSshAgentForAuth: async (options) => {
if (options.hostname === "jump.example") {
const error = new Error("Identity selector unavailable");
error.code = "ERR_SSH_AGENT_IDENTITY_SELECTOR_UNAVAILABLE";
throw error;
}
return {
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
};
},
getAvailableAgentSocket: async (identityAgent) => identityAgent,
});
await assert.rejects(
() => api.prepareEtSshAgentOptions({
hostname: "dest.example",
username: "alice",
useSshAgent: true,
identityAgent: "/tmp/target.sock",
jumpHosts: [{
hostname: "jump.example",
username: "ops",
useSshAgent: true,
identityAgent: "/tmp/jump.sock",
identitiesOnly: true,
agentPublicKeys: ["ssh-ed25519 AAAAJUMPSELECTED"],
}],
}),
(error) => error?.code === "ERR_SSH_AGENT_IDENTITY_SELECTOR_UNAVAILABLE",
);
assert.equal(releaseCount, 1);
});
test("prepareEtSshAgentOptions releases the current hop FIDO agent when forwarding setup fails", async (t) => {
let releaseCount = 0;
const { api } = makeApi(t, {
prepareSystemSshAgentForAuth: async () => ({
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
}),
getAvailableAgentSocket: async () => "/tmp/login-agent.sock",
getAvailableForwardingAgentSocket: async () => {
const error = new Error(
"This SSH agent is available only to Netcatty's built-in SSH client.",
);
error.code = "ERR_SSH_AGENT_NATIVE_UNSUPPORTED";
throw error;
},
});
await assert.rejects(
() => api.prepareEtSshAgentOptions({
hostname: "dest.example",
username: "alice",
useSshAgent: true,
agentForwarding: true,
}),
(error) => error?.code === "ERR_SSH_AGENT_NATIVE_UNSUPPORTED",
);
assert.equal(releaseCount, 1);
});
test("startEtSession exposes a one-shot FIDO release that survives map deletion", async (t) => {
let releaseCount = 0;
let onExitHandler = null;
const flushCallbacks = [];
const proc = {
onData() {},
onExit(handler) { onExitHandler = handler; },
write() {},
kill() {},
};
const { api, sessions } = makeApi(t, {
bundledEtClient: () => "/fake/et",
prepareSystemSshAgentForAuth: async () => ({
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
}),
getAvailableAgentSocket: async () => "/tmp/login-agent.sock",
createPtyOutputBuffer: () => ({
bufferData() {},
flush() {},
flushPaced(cb) { flushCallbacks.push(cb); },
discard() {},
}),
pty: { spawn: () => proc },
electronModule: { webContents: { fromId: () => null } },
openTerminalOutputSession: () => {},
closeTerminalOutputSession: () => {},
selectZmodemUploadFiles: null,
selectZmodemDownloadDirectory: null,
});
await api.startEtSession({ sender: { id: 7 } }, {
sessionId: "sess-fido-close",
hostname: "host.example",
username: "alice",
useSshAgent: true,
});
const session = sessions.get("sess-fido-close");
assert.equal(typeof session.releaseNetcattyFidoAgents, "function");
// Explicit close deletes the session before PTY onExit runs.
sessions.delete("sess-fido-close");
assert.ok(onExitHandler);
onExitHandler({ exitCode: 0 });
for (const cb of flushCallbacks) cb();
assert.equal(releaseCount, 1);
// closeSession / quit may also invoke the session-stored one-shot release.
session.releaseNetcattyFidoAgents();
assert.equal(releaseCount, 1);
});
test("prepareEtSshEnvironment writes legacy algorithms to the ssh config file", (t) => {
const { api } = makeApi(t);
const env = api.prepareEtSshEnvironment("sess1", {
+92 -42
View File
@@ -145,24 +145,69 @@ function createMoshSessionApi(ctx) {
}
}
async function prepareMoshSshAgentOptions(options) {
if (options?.useSshAgent !== true && !options?.agentForwarding) return options;
async function prepareMoshSshAgentOptions(options, sender) {
const {
enhanceAuthOptionsForFido,
resolvePreparedAgentSocket,
} = require("../sshAuthHelper.cjs");
const prepOptions = await enhanceAuthOptionsForFido(options, sender);
const forceFido = prepOptions.useFidoAgent === true && options.authMethod !== "password";
if (options?.useSshAgent !== true && !options?.agentForwarding && !forceFido) return options;
let prepared = options;
if (options.useSshAgent === true) {
await prepareSystemSshAgentForAuth(options, "[Mosh]");
const loginSocketPath = await getAvailableAgentSocket(options.identityAgent, options);
if (!loginSocketPath) {
throw new Error("System SSH agent is unavailable. Start or unlock it, or configure a valid agent socket.");
/** @type {(() => void)|null} */
let releasePreparedAgent = null;
try {
if (options.useSshAgent === true || forceFido) {
const agent = await prepareSystemSshAgentForAuth(prepOptions, "[Mosh]");
if (typeof agent?._releaseNetcattyFidoAgent === "function") {
releasePreparedAgent = agent._releaseNetcattyFidoAgent;
}
const socketPath = resolvePreparedAgentSocket(agent, prepOptions)
|| await getAvailableAgentSocket(options.identityAgent, options);
if (!socketPath) {
throw new Error(
forceFido
? "FIDO2 SSH agent is unavailable. Install OpenSSH with libfido2 and try again."
: "System SSH agent is unavailable. Start or unlock it, or configure a valid agent socket.",
);
}
prepared = {
...prepared,
useSshAgent: true,
_resolvedSshAgentSocket: socketPath,
...(releasePreparedAgent
? { _releaseNetcattyFidoAgent: releasePreparedAgent }
: {}),
};
}
prepared = { ...prepared, _resolvedSshAgentSocket: loginSocketPath };
}
if (options.agentForwarding) {
const forwardingSocketPath = await getAvailableForwardingAgentSocket(options.identityAgent, options);
if (forwardingSocketPath) {
prepared = { ...prepared, _resolvedForwardingAgentSocket: forwardingSocketPath };
if (options.agentForwarding) {
// May throw (e.g. Windows Pageant/Cygwin rejected for native OpenSSH).
// Release any already-acquired FIDO agent/askpass lease before rethrowing.
const forwardingSocketPath = await getAvailableForwardingAgentSocket(
options.identityAgent,
options,
);
if (forwardingSocketPath) {
prepared = { ...prepared, _resolvedForwardingAgentSocket: forwardingSocketPath };
}
}
return prepared;
} catch (error) {
if (typeof releasePreparedAgent === "function") {
try { releasePreparedAgent(); } catch { /* ignore */ }
}
throw error;
}
return prepared;
}
function createOneShotFidoAgentRelease(releaseFn) {
if (typeof releaseFn !== "function") return () => {};
let released = false;
return () => {
if (released) return;
released = true;
try { releaseFn(); } catch { /* ignore */ }
};
}
function applyMoshSshAgentEnvironment(env, options) {
@@ -480,38 +525,40 @@ function createMoshSessionApi(ctx) {
*/
async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe }) {
const sessionId = options.sessionId || randomUUID();
const releaseFidoAgent = createOneShotFidoAgentRelease(options._releaseNetcattyFidoAgent);
const cols = options.cols || 80;
const rows = options.rows || 24;
const optionsEnv = options.env || {};
const lang = optionsEnv.LANG || resolveLangFromCharsetForMosh(options.charset);
const moshAuth = await buildMoshSshAuthArgs(options, sessionId);
const { args: sshArgs } = moshHandshake.buildSshHandshakeCommand({
host: options.hostname,
port: options.port,
username: options.username,
lang,
locales: optionsEnv,
moshServer: moshHandshake.buildMoshServerCommand(options.moshServerPath),
sshArgs: moshAuth.sshArgs,
});
const { buildTerminalProcessEnv } = require("../httpNetworkProxyBridge.cjs");
const sshEnv = { ...buildTerminalProcessEnv(process.env), ...optionsEnv, TERM: "xterm-256color" };
// Do not let ssh_config SendEnv force the local locale onto the remote
// process. The handshake passes the configured locale variables through
// mosh-server's stock `-l` fallback mechanism instead, so a minimal host
// can keep its working native C.UTF-8 locale when a requested locale is
// not installed.
for (const key of Object.keys(sshEnv)) {
if (key === "LANG" || key === "LANGUAGE" || key.startsWith("LC_")) {
delete sshEnv[key];
}
}
applyMoshSshAgentEnvironment(sshEnv, options);
let moshAuth;
let sshPty;
try {
moshAuth = await buildMoshSshAuthArgs(options, sessionId);
const { args: sshArgs } = moshHandshake.buildSshHandshakeCommand({
host: options.hostname,
port: options.port,
username: options.username,
lang,
locales: optionsEnv,
moshServer: moshHandshake.buildMoshServerCommand(options.moshServerPath),
sshArgs: moshAuth.sshArgs,
});
const { buildTerminalProcessEnv } = require("../httpNetworkProxyBridge.cjs");
const sshEnv = { ...buildTerminalProcessEnv(process.env), ...optionsEnv, TERM: "xterm-256color" };
// Do not let ssh_config SendEnv force the local locale onto the remote
// process. The handshake passes the configured locale variables through
// mosh-server's stock `-l` fallback mechanism instead, so a minimal host
// can keep its working native C.UTF-8 locale when a requested locale is
// not installed.
for (const key of Object.keys(sshEnv)) {
if (key === "LANG" || key === "LANGUAGE" || key.startsWith("LC_")) {
delete sshEnv[key];
}
}
applyMoshSshAgentEnvironment(sshEnv, options);
sshPty = pty.spawn(sshExe, sshArgs, {
cols,
rows,
@@ -521,7 +568,8 @@ function createMoshSessionApi(ctx) {
useConptyDll: process.platform === "win32",
});
} catch (err) {
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
if (moshAuth) cleanupMoshAuthTempFiles(moshAuth.tempFiles);
releaseFidoAgent();
throw err;
}
@@ -564,6 +612,7 @@ function createMoshSessionApi(ctx) {
if (!claim.ok) {
try { sshPty.kill(); } catch { /* ignore */ }
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
releaseFidoAgent();
const supersededError = new Error("Connection superseded by a newer reconnect");
supersededError.code = "NETCATTY_BOOT_SUPERSEDED";
throw supersededError;
@@ -641,6 +690,7 @@ function createMoshSessionApi(ctx) {
});
sshPty.onExit(({ exitCode, signal }) => {
releaseFidoAgent();
if (sessions.get(sessionId) !== session || session.closed) {
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
return;
@@ -961,7 +1011,7 @@ function createMoshSessionApi(ctx) {
throw new Error("OpenSSH client not found. Netcatty needs ssh to start the remote mosh-server handshake.");
}
const preparedOptions = await prepareMoshSshAgentOptions(options);
const preparedOptions = await prepareMoshSshAgentOptions(options, event?.sender);
return startMoshSessionViaHandshake(event, preparedOptions, { bareClient, sshExe });
}
+37
View File
@@ -50,6 +50,9 @@ const passphraseListeners = new Set();
const passphraseTimeoutListeners = new Set();
const passphraseCancelledListeners = new Set();
const passphraseAuthFailedListeners = new Set();
const fidoPromptListeners = new Set();
const fidoPromptTimeoutListeners = new Set();
const fidoPromptCancelledListeners = new Set();
const updateDownloadProgressListeners = new Set();
const updateDownloadedListeners = new Set();
const updateAvailableListeners = new Set();
@@ -711,6 +714,37 @@ ipcRenderer.on("netcatty:passphrase-auth-failed", (_event, payload) => {
});
});
// FIDO2 PIN / touch presence prompts
ipcRenderer.on("netcatty:fido-prompt-request", (_event, payload) => {
fidoPromptListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("FIDO prompt request callback failed", err);
}
});
});
ipcRenderer.on("netcatty:fido-prompt-timeout", (_event, payload) => {
fidoPromptTimeoutListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("FIDO prompt timeout callback failed", err);
}
});
});
ipcRenderer.on("netcatty:fido-prompt-cancelled", (_event, payload) => {
fidoPromptCancelledListeners.forEach((cb) => {
try {
cb(payload);
} catch (err) {
console.error("FIDO prompt cancelled callback failed", err);
}
});
});
// Auto-update events
ipcRenderer.on("netcatty:update:update-available", (_event, payload) => {
updateAvailableListeners.forEach((cb) => {
@@ -889,6 +923,9 @@ const api = createPreloadApi({
passphraseTimeoutListeners,
passphraseCancelledListeners,
passphraseAuthFailedListeners,
fidoPromptListeners,
fidoPromptTimeoutListeners,
fidoPromptCancelledListeners,
updateDownloadProgressListeners,
updateDownloadedListeners,
updateAvailableListeners,
+20
View File
@@ -844,6 +844,26 @@ function createPreloadApi(ctx) {
passphraseAuthFailedListeners.add(cb);
return () => passphraseAuthFailedListeners.delete(cb);
},
// FIDO2 PIN / touch presence prompts
onFidoPromptRequest: (cb) => {
fidoPromptListeners.add(cb);
return () => fidoPromptListeners.delete(cb);
},
respondFidoPrompt: async (requestId, response, cancelled = false) => {
return ipcRenderer.invoke("netcatty:fido-prompt:respond", {
requestId,
response,
cancelled,
});
},
onFidoPromptTimeout: (cb) => {
fidoPromptTimeoutListeners.add(cb);
return () => fidoPromptTimeoutListeners.delete(cb);
},
onFidoPromptCancelled: (cb) => {
fidoPromptCancelledListeners.add(cb);
return () => fidoPromptCancelledListeners.delete(cb);
},
openSftp: async (options) => {
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
return result.sftpId;
Vendored
+1
View File
@@ -93,6 +93,7 @@ declare global {
}
interface NetcattySSHOptions {
keyLabel?: string;
sessionId?: string;
hostId?: string;
hostLabel?: string;
+54
View File
@@ -442,6 +442,60 @@ html.platform-win32 {
will-change: width, opacity;
}
/* Security-key prompts use the same tokens as the shared dialog. Motion stays
local to this flow and preserves the dialog's centered transform. */
@keyframes fido-dialog-enter {
from { opacity: 0; transform: translateY(8px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes fido-dialog-exit {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(4px) scale(0.98); }
}
@keyframes fido-field-enter {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fido-ring-expand {
0% { opacity: 0; transform: scale(0.65); }
25% { opacity: 0.65; }
100% { opacity: 0; transform: scale(1.15); }
}
@keyframes fido-breathe {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.fido-prompt-dialog[data-state="open"] {
animation: fido-dialog-enter 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.fido-prompt-dialog[data-state="closed"] {
animation: fido-dialog-exit 140ms ease-in both;
}
.fido-prompt-overlay[data-state="open"] {
animation: fade-in 180ms ease-out both;
}
.fido-prompt-overlay[data-state="closed"] {
animation: fade-in 140ms ease-in reverse both;
}
.fido-prompt-stage, .fido-field-stage {
animation: fido-field-enter 180ms ease-out both;
}
.fido-touch-ring {
animation: fido-ring-expand 2.4s ease-out infinite;
}
.fido-touch-ring-delayed { animation-delay: 1.2s; }
.fido-status-dot { animation: fido-breathe 2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.fido-prompt-dialog, .fido-prompt-overlay, .fido-prompt-stage,
.fido-field-stage, .fido-touch-ring, .fido-status-dot,
.fido-prompt-dialog .animate-spin,
.fido-key-options [role="switch"], .fido-key-options [role="switch"] span {
animation: none !important;
transition: none !important;
}
.fido-touch-ring { display: none; }
}
:root {
color-scheme: light;
}
+2
View File
@@ -62,6 +62,8 @@
"test:plugin-runtime:electron": "electron electron/plugins/electronRuntime.live.test.cjs",
"test:sync-crdt": "node --test --import tsx domain/convergentSync/*.test.ts",
"bench:sync-crdt": "tsx scripts/bench-sync-crdt.ts",
"test:fido-sim": "cross-env NETCATTY_FIDO_SIM=1 node --test scripts/fido-auth.live.test.cjs",
"test:fido-docker": "cross-env NETCATTY_FIDO_DOCKER=1 node --test scripts/fido-auth.docker.test.cjs",
"test:ssh-mfa-models": "node --test electron/bridges/sshMfaModels.live.test.cjs",
"test:ssh-mfa-models:live": "SSH_MFA_LIVE=1 node --test electron/bridges/sshMfaModels.live.test.cjs",
"test:local-file-drag:electron": "electron scripts/local-file-drag.live.test.cjs",
+582 -91
View File
File diff suppressed because it is too large. Load diff
+250
View File
@@ -0,0 +1,250 @@
"use strict";
// An independent Docker sshd verifies signatures made through the production
// Netcatty -> ssh-agent -> ssh-sk-helper -> software authenticator path.
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const { execFileSync, spawnSync } = require("node:child_process");
const { Client } = require("ssh2");
const { generateFidoSshKeyPair } = require("../electron/bridges/fidoSshKeygen.cjs");
const { enhanceAuthOptionsForFido, prepareSystemSshAgentForAuth } = require("../electron/bridges/sshAuthHelper.cjs");
const { shutdownFidoAgentSubsystem } = require("../electron/bridges/fidoAgentManager.cjs");
const prompts = require("../electron/bridges/fidoPromptHandler.cjs");
const askpass = require("../electron/bridges/fidoAskpass.cjs");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const { attachFidoAgentRelease } = require("../electron/bridges/attachFidoAgentRelease.cjs");
async function until(check, description) {
const deadline = Date.now() + 5000;
while (!check()) {
if (Date.now() > deadline) throw new Error(`Timed out: ${description}`);
await new Promise(resolve => setTimeout(resolve, 10));
}
}
function exec(client, command) {
return new Promise((resolve, reject) => client.exec(command, (error, channel) => {
if (error) return reject(error);
let output = "";
channel.on("data", chunk => { output += chunk; });
channel.on("error", reject);
channel.on("close", code => code === 0 ? resolve(output) : reject(new Error(`exec exit ${code}`)));
}));
}
test("Docker OpenSSH accepts only completed FIDO PIN/presence authentication", {
skip: process.env.NETCATTY_FIDO_DOCKER !== "1", timeout: 180000,
}, async t => {
const dir = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fd-"));
const fixtures = path.join(dir, "server");
fs.mkdirSync(fixtures, { mode: 0o755 });
const authorized = path.join(fixtures, "authorized_keys");
fs.writeFileSync(authorized, "", { mode: 0o644 });
const control = path.join(dir, "control");
const provider = path.join(dir, "provider.so");
const wrapper = path.join(dir, "ssh-agent");
const name = `netcatty-fido-${randomUUID().slice(0, 8)}`;
const envKeys = ["SSH_SK_PROVIDER", "NETCATTY_SSH_AGENT_PATH", "NETCATTY_FIDO_SIM_CONTROL"];
const oldEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]));
const docker = (...args) => {
const result = spawnSync("docker", args, { encoding: "utf8", timeout: 120000 });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`docker ${args[0]}: ${result.stderr}`);
// docker logs preserves the container's stdout/stderr streams.
return result.stdout + (args[0] === "logs" ? result.stderr : "");
};
let started = false;
let answer = "123456";
let cancelPin = false;
let retryMissing = false;
let usePassword = false;
const seen = [];
const dismissed = new Set();
const sender = {
id: 2823, isDestroyed: () => false,
send(channel, request) {
if (channel === "netcatty:fido-prompt-cancelled") dismissed.add(request.requestId);
if (channel !== "netcatty:fido-prompt-request") return;
seen.push(request);
// Device notifications deliberately get NO renderer response.
if (request.notificationOnly) return;
if (request.kind === "recovery") {
if (usePassword) queueMicrotask(() => prompts.handleResponse({ sender }, {
requestId: request.requestId, response: "fallback", cancelled: false,
}));
else if (retryMissing && request.reason === "device-unavailable") {
fs.writeFileSync(control, "touch");
queueMicrotask(() => prompts.handleResponse({ sender }, {
requestId: request.requestId, response: "retry", cancelled: false,
}));
} else queueMicrotask(() => prompts.handleResponse({ sender }, {
requestId: request.requestId, cancelled: true,
}));
return;
}
queueMicrotask(() => prompts.handleResponse({ sender }, {
requestId: request.requestId, response: answer, cancelled: cancelPin,
}));
},
};
try {
docker("info", "--format", "{{.ServerVersion}}");
docker("build", "--quiet", "-t", "netcatty-fido-test", path.join(__dirname, "fixtures/fido/docker"));
docker("run", "--rm", "-d", "--name", name, "--publish", "127.0.0.1::2222",
"--mount", `type=bind,source=${fixtures},target=/fixtures,readonly`, "netcatty-fido-test",
"sh", "-c", "ssh-keygen -A && exec /usr/sbin/sshd -D -e -o PasswordAuthentication=yes");
started = true;
const port = Number(JSON.parse(docker("inspect", name))[0].NetworkSettings.Ports["2222/tcp"][0].HostPort);
await until(() => docker("logs", name).includes("Server listening"), "Docker sshd listening");
const password = "Netcatty-disposable-test-password";
execFileSync("docker", ["exec", "-i", name, "chpasswd"], { input: `netcatty:${password}\n` });
execFileSync("cc", ["-shared", "-fPIC", "-DHAVE_STDINT_H",
path.join(__dirname, "fixtures/fido/provider.c"), "-lcrypto", "-o", provider]);
fs.writeFileSync(wrapper, `#!/bin/sh\nexec /usr/bin/ssh-agent -P '${provider}' "$@"\n`, { mode: 0o700 });
Object.assign(process.env, {
SSH_SK_PROVIDER: provider, NETCATTY_SSH_AGENT_PATH: wrapper, NETCATTY_FIDO_SIM_CONTROL: control,
});
askpass.setHelperTimeoutForTests(1500);
for (const type of ["ED25519-SK", "ECDSA-SK"]) {
for (const verifyRequired of [false, true]) {
const label = `${type} / ${verifyRequired ? "PIN + touch" : "touch only"}`;
const generated = await generateFidoSshKeyPair({ type, verifyRequired, resolveWebContents: () => sender });
assert.equal(generated.success, true, generated.error);
fs.writeFileSync(authorized, `${verifyRequired ? "verify-required " : ""}${generated.publicKey}\n`);
const options = await enhanceAuthOptionsForFido({
hostname: "127.0.0.1", port, username: "netcatty", privateKey: generated.privateKey,
keyLabel: "Integration security key",
useSshAgent: false, identitiesOnly: true,
}, sender);
const agent = await prepareSystemSshAgentForAuth(options, "[DockerFido]");
assert.ok(agent);
async function attempt(action, { shouldPass = false, touchLater = false, unplugLater = false, reason } = {}) {
fs.rmSync(`${control}.waiting`, { force: true });
fs.writeFileSync(control, action);
const before = seen.length;
const client = new Client();
// ssh2 may emit both an agent error and the final auth failure.
client.on("error", () => {});
let ready = false;
client.once("ready", () => { ready = true; });
// Attach both outcomes before connecting: denied authentication is expected.
const outcome = new Promise(resolve => {
client.once("ready", () => resolve({ ready: true }));
client.once("error", error => resolve({ error }));
});
try {
client.connect({ host: "127.0.0.1", port, username: "netcatty", agent, readyTimeout: 12000 });
if (touchLater || unplugLater) {
await until(() => fs.existsSync(`${control}.waiting`), "authenticator waiting for touch");
// A valid PIN or a notification is not sufficient to log in.
await new Promise(resolve => setTimeout(resolve, 200));
assert.equal(ready, false, "must wait for physical presence");
await until(() => seen.slice(before).some(p => p.notificationOnly), "touch waiting notification");
fs.writeFileSync(control, unplugLater ? "unplugged" : "touch");
}
const result = await outcome;
if (shouldPass) {
assert.equal(result.ready, true, result.error?.message);
assert.equal(await exec(client, "printf docker-fido-ok"), "docker-fido-ok");
const sftp = await new Promise((resolve, reject) => client.sftp((error, value) => error ? reject(error) : resolve(value)));
const stat = await new Promise((resolve, reject) => sftp.stat("/fixtures/authorized_keys", (error, value) => error ? reject(error) : resolve(value)));
assert.ok(stat.size > 0);
sftp.end();
} else {
assert.equal(ready, false, "invalid authentication must not become ready");
assert.match(result.error?.message || "", /authentication methods failed|agent|sign|cancelled/i);
}
const current = seen.slice(before);
assert.equal(current.filter(p => p.kind === "pin").length,
verifyRequired && action !== "unsupported" && (action !== "unplugged" || retryMissing) ? 1 : 0);
const recovery = current.filter(p => p.kind === "recovery");
assert.deepEqual(recovery.map(p => p.reason), reason ? [reason] : []);
assert.ok(recovery.every(p => p.keyName === "Integration security key" && !p.allowPasswordFallback));
const notifications = current.filter(p => p.kind === "touch");
assert.ok(notifications.every(p => p.notificationOnly === true));
await until(() => notifications.every(p => dismissed.has(p.requestId)), "automatic notification dismissal");
} finally {
client.end();
await until(() => prompts.getRequests().size === 0, "no stale prompt");
}
}
try {
await t.test(`${label}: gated login, automatic continuation, exec and SFTP`, () =>
attempt("wait", { shouldPass: true, touchLater: true }));
await t.test(`${label}: no touch times out and stops the helper`, () => attempt("wait", { reason: "timeout" }));
await t.test(`${label}: missing token requests the matching device`, () => attempt("unplugged", { reason: "device-unavailable" }));
await t.test(`${label}: unsupported hardware is not misreported as a missing device`, () => attempt("unsupported", { reason: "failed" }));
await t.test(`${label}: removal during confirmation is distinct from timeout`, () => attempt("wait", { unplugLater: true, reason: "device-unavailable" }));
await t.test(`${label}: insert and explicitly retry completes the pending login`, async () => {
retryMissing = true;
try { await attempt("unplugged", { shouldPass: true, reason: "device-unavailable" }); }
finally { retryMissing = false; }
});
await t.test(`${label}: server rejects signature without presence flag`, () => attempt("no-up"));
if (verifyRequired) {
await t.test(`${label}: wrong PIN is denied`, async () => {
answer = "000000";
try { await attempt("touch", { reason: "pin-failed" }); } finally { answer = "123456"; }
});
await t.test(`${label}: cancelled PIN is denied`, async () => {
cancelPin = true;
try { await attempt("touch"); } finally { cancelPin = false; }
});
await t.test(`${label}: server rejects signature without verification flag`, () => attempt("no-uv"));
}
await t.test(`${label}: a fresh login still succeeds after failures`, () => attempt("touch", { shouldPass: true }));
} finally { agent._releaseNetcattyFidoAgent?.(); }
}
}
for (const fallback of [false, true]) {
await t.test(fallback ? "explicit password choice permits configured fallback" : "cancel never falls back to the configured password", async () => {
usePassword = fallback;
const generated = await generateFidoSshKeyPair({ type: "ED25519-SK", verifyRequired: true, resolveWebContents: () => sender });
assert.equal(generated.success, true, generated.error);
fs.writeFileSync(authorized, `verify-required ${generated.publicKey}\n`);
const options = await enhanceAuthOptionsForFido({ hostname: "127.0.0.1", port, username: "netcatty",
privateKey: generated.privateKey, password, keyLabel: "Fallback test key", identitiesOnly: true }, sender);
const agent = await prepareSystemSshAgentForAuth(options, "[DockerFidoFallback]");
const client = new Client();
client.on("error", () => {});
attachFidoAgentRelease(client, agent);
const before = seen.length;
const outcome = new Promise(resolve => {
client.once("ready", () => resolve(true));
client.once("error", error => { if (error.code === "ERR_FIDO_CANCELLED") resolve(false); });
});
fs.writeFileSync(control, "unplugged");
try {
client.connect({ host: "127.0.0.1", port, username: "netcatty", agent, password,
authHandler: ["agent", "password"], readyTimeout: 10000 });
assert.equal(await outcome, fallback);
const recovery = seen.slice(before).find(p => p.kind === "recovery");
assert.equal(recovery.reason, "device-unavailable");
assert.equal(recovery.allowPasswordFallback, true);
if (fallback) assert.equal(await exec(client, "printf explicit-password-ok"), "explicit-password-ok");
} finally { client.end(); agent._releaseNetcattyFidoAgent?.(); usePassword = false; }
});
}
const logs = docker("logs", name);
assert.match(logs, /Accepted publickey/);
// These messages prove server-side UP/UV enforcement, independently of UI.
assert.match(logs, /user presence.*requirement not met/);
assert.match(logs, /user verification.*requirement not met/);
} catch (error) {
if (started) error.message += `\nDocker sshd log:\n${docker("logs", name)}`;
throw error;
} finally {
shutdownFidoAgentSubsystem();
askpass.shutdownFidoAskpass();
askpass.setHelperTimeoutForTests(null);
if (started) docker("rm", "-f", name);
for (const [key, value] of Object.entries(oldEnv)) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
fs.rmSync(dir, { recursive: true, force: true });
}
});
+213
View File
@@ -0,0 +1,213 @@
"use strict";
// Opt in with NETCATTY_FIDO_SIM=1. Uses a disposable SOFTWARE authenticator,
// real OpenSSH keygen/agent/sk-helper/sshd and Netcatty's production auth helpers.
// Requires Linux, a C compiler, OpenSSL headers and OpenSSH client/server.
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const net = require("node:net");
const { execFileSync, spawn } = require("node:child_process");
const { once } = require("node:events");
const { Client } = require("ssh2");
const { generateFidoSshKeyPair } = require("../electron/bridges/fidoSshKeygen.cjs");
const { enhanceAuthOptionsForFido, prepareSystemSshAgentForAuth } = require("../electron/bridges/sshAuthHelper.cjs");
const { shutdownFidoAgentSubsystem } = require("../electron/bridges/fidoAgentManager.cjs");
const prompts = require("../electron/bridges/fidoPromptHandler.cjs");
const askpass = require("../electron/bridges/fidoAskpass.cjs");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
async function freePort() {
const server = net.createServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const port = server.address().port;
await new Promise((resolve) => server.close(resolve));
return port;
}
function exec(client, command) {
return new Promise((resolve, reject) => {
client.exec(command, (error, channel) => {
if (error) return reject(error);
let output = "";
channel.on("data", (chunk) => { output += chunk; });
channel.on("error", reject);
channel.on("close", (code) => code === 0 ? resolve(output) : reject(new Error(`exec exit ${code}`)));
});
});
}
test("software FIDO authenticator authenticates against real OpenSSH", {
skip: process.env.NETCATTY_FIDO_SIM !== "1",
timeout: 120000,
}, async (t) => {
const dir = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fs-"));
const provider = path.join(dir, "provider.so");
const wrapper = path.join(dir, "ssh-agent");
const originalEnv = {
SSH_SK_PROVIDER: process.env.SSH_SK_PROVIDER,
NETCATTY_SSH_AGENT_PATH: process.env.NETCATTY_SSH_AGENT_PATH,
};
let daemon;
let serverLog = "";
const receivedPrompts = [];
const routedPrompts = [];
const sender = {
id: 2823, isDestroyed: () => false,
send(channel, request) {
if (channel !== "netcatty:fido-prompt-request") return;
receivedPrompts.push(request.kind);
routedPrompts.push(sender.id);
console.log("[FidoSimulation] prompt", request.kind, request.message);
queueMicrotask(() => prompts.handleResponse({ sender }, {
requestId: request.requestId,
response: request.kind === "pin" ? "123456" : "",
cancelled: false,
}));
},
};
try {
execFileSync("cc", ["-shared", "-fPIC", "-DHAVE_STDINT_H",
path.join(__dirname, "fixtures/fido/provider.c"), "-lcrypto", "-o", provider]);
fs.writeFileSync(wrapper, `#!/bin/sh\nexec /usr/bin/ssh-agent -P '${provider}' "$@"\n`, { mode: 0o700 });
process.env.SSH_SK_PROVIDER = provider;
process.env.NETCATTY_SSH_AGENT_PATH = wrapper;
execFileSync("ssh-keygen", ["-t", "ed25519", "-N", "", "-f", path.join(dir, "host")]);
const port = await freePort();
const authorizedKeys = path.join(dir, "authorized_keys");
fs.writeFileSync(authorizedKeys, "");
const config = path.join(dir, "sshd_config");
fs.writeFileSync(config, [
`Port ${port}`, "ListenAddress 127.0.0.1", `HostKey ${dir}/host`,
`AuthorizedKeysFile ${authorizedKeys}`, `PidFile ${dir}/sshd.pid`,
"StrictModes no", "UsePAM no", "PasswordAuthentication no",
"KbdInteractiveAuthentication no", "LogLevel VERBOSE", "Subsystem sftp internal-sftp",
].join("\n") + "\n");
daemon = spawn("/usr/sbin/sshd", ["-D", "-e", "-f", config], { stdio: ["ignore", "ignore", "pipe"] });
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`sshd start timeout: ${serverLog}`)), 5000);
daemon.once("error", reject);
daemon.once("exit", (code) => { clearTimeout(timer); reject(new Error(`sshd exit ${code}: ${serverLog}`)); });
daemon.stderr.on("data", (chunk) => {
serverLog += chunk;
if (serverLog.includes("Server listening")) { clearTimeout(timer); resolve(); }
});
});
for (const type of ["ED25519-SK", "ECDSA-SK"]) {
await t.test(`${type}: generation, PIN/touch, tokenized reference, SSH exec, SFTP`, async () => {
const beforePins = receivedPrompts.filter((kind) => kind === "pin").length;
const generated = await generateFidoSshKeyPair({
type, verifyRequired: true, resident: true, resolveWebContents: () => sender,
});
assert.equal(generated.success, true, generated.error);
assert.equal(generated.keyType, type);
fs.appendFileSync(authorizedKeys, `verify-required ${generated.publicKey}\n`);
const username = require("node:os").userInfo().username;
const keyPath = path.join(dir, `id_${username}_${type}`);
fs.writeFileSync(keyPath, generated.privateKey, { mode: 0o600 });
fs.writeFileSync(`${keyPath}.pub`, generated.publicKey);
// Sign a user certificate with a disposable CA and require that
// certificate on the server for the second connection below.
execFileSync("ssh-keygen", ["-s", path.join(dir, "host"), "-I", "fido-sim",
"-n", username, "-V", "-1m:+5m", "-O", "verify-required", `${keyPath}.pub`]);
const certificate = fs.readFileSync(`${keyPath}-cert.pub`, "utf8").trim();
// Probe must use the same %r expansion as ssh-add, before deciding agent auth.
const options = await enhanceAuthOptionsForFido({
hostname: "127.0.0.1", port, username, useSshAgent: false,
identityFilePaths: [path.join(dir, `id_%r_${type}`)], identitiesOnly: true,
}, sender);
assert.equal(options.useFidoAgent, true);
const agent = await prepareSystemSshAgentForAuth(options, "[FidoSimulation]");
assert.ok(agent);
const client = new Client();
try {
client.connect({ host: options.hostname, port, username, agent, readyTimeout: 15000 });
await once(client, "ready");
assert.equal(await exec(client, "printf netcatty-fido-ok"), "netcatty-fido-ok");
const sftp = await new Promise((resolve, reject) => client.sftp((error, session) => error ? reject(error) : resolve(session)));
const stat = await new Promise((resolve, reject) => sftp.stat(authorizedKeys, (error, value) => error ? reject(error) : resolve(value)));
assert.ok(stat.size > 0);
sftp.end();
const echo = net.createServer((socket) => socket.pipe(socket));
echo.listen(0, "127.0.0.1");
await once(echo, "listening");
try {
const stream = await new Promise((resolve, reject) => client.forwardOut(
"127.0.0.1", 12345, "127.0.0.1", echo.address().port,
(error, channel) => error ? reject(error) : resolve(channel),
));
const data = once(stream, "data");
stream.write("fido-forward-ok");
assert.equal(String((await data)[0]), "fido-forward-ok");
stream.end();
} finally { await new Promise((resolve) => echo.close(resolve)); }
// A signing PIN prompt must have crossed helper subprocess -> socket -> IPC.
assert.ok(receivedPrompts.filter((kind) => kind === "pin").length >= beforePins + 2);
const otherSender = {
id: 2824, isDestroyed: () => false,
send(channel, request) {
if (channel !== "netcatty:fido-prompt-request") return;
routedPrompts.push(otherSender.id);
queueMicrotask(() => prompts.handleResponse({ sender: otherSender }, {
requestId: request.requestId, response: "123456", cancelled: false,
}));
},
};
const otherAgent = await prepareSystemSshAgentForAuth({
...options, resolveWebContents: () => otherSender,
}, "[FidoSecondWindow]");
const pair = [new Client(), new Client()];
try {
const beforeRouting = routedPrompts.length;
await Promise.all(pair.map(async (connection, index) => {
connection.connect({ host: options.hostname, port, username,
agent: index ? otherAgent : agent, readyTimeout: 15000 });
await once(connection, "ready");
assert.equal(await exec(connection, "printf multi-window-ok"), "multi-window-ok");
}));
assert.deepEqual(new Set(routedPrompts.slice(beforeRouting)), new Set([sender.id, otherSender.id]));
} finally {
pair.forEach((connection) => connection.end());
otherAgent._releaseNetcattyFidoAgent?.();
}
} finally {
client.end();
agent._releaseNetcattyFidoAgent?.();
}
fs.writeFileSync(authorizedKeys,
`cert-authority,verify-required,principals="${username}" ${fs.readFileSync(path.join(dir, "host.pub"), "utf8")}`);
const certAgent = await prepareSystemSshAgentForAuth({
...options, certificate,
...(type === "ECDSA-SK" ? { privateKey: generated.privateKey, identityFilePaths: [] } : {}),
}, "[FidoCertificateSimulation]");
const certClient = new Client();
try {
certClient.connect({ host: options.hostname, port, username, agent: certAgent, readyTimeout: 15000 });
await once(certClient, "ready");
assert.equal(await exec(certClient, "printf fido-cert-ok"), "fido-cert-ok");
} finally {
certClient.end();
certAgent._releaseNetcattyFidoAgent?.();
fs.writeFileSync(authorizedKeys, "");
}
});
}
assert.match(serverLog, /Accepted publickey/);
assert.ok(receivedPrompts.includes("pin"));
} catch (error) {
error.message += `\nOpenSSH server log:\n${serverLog}`;
throw error;
} finally {
daemon?.kill();
shutdownFidoAgentSubsystem();
askpass.shutdownFidoAskpass();
for (const [name, value] of Object.entries(originalEnv)) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
fs.rmSync(dir, { recursive: true, force: true });
}
});
+222
View File
@@ -0,0 +1,222 @@
"use strict";
// Native Windows Node + real MSYS2 OpenSSH + a disposable software SK provider.
// This deliberately does not stand in for USB/CTAP or Windows WebAuthn testing.
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { generateKeyPairSync } = require("node:crypto");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const { once } = require("node:events");
const { Client, Server, utils } = require("ssh2");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const askpass = require("../electron/bridges/fidoAskpass.cjs");
const prompts = require("../electron/bridges/fidoPromptHandler.cjs");
const { generateFidoSshKeyPair } = require("../electron/bridges/fidoSshKeygen.cjs");
const { enhanceAuthOptionsForFido, prepareSystemSshAgentForAuth } = require("../electron/bridges/sshAuthHelper.cjs");
const { shutdownFidoAgentSubsystem } = require("../electron/bridges/fidoAgentManager.cjs");
const { attachFidoAgentRelease } = require("../electron/bridges/attachFidoAgentRelease.cjs");
const run = promisify(execFile);
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
async function until(check) {
const deadline = Date.now() + 10000;
while (!check()) {
if (Date.now() > deadline) throw new Error("Timed out waiting for software authenticator");
await pause(25);
}
}
test("Windows security-key integration with real user-mode OpenSSH", {
skip: process.platform !== "win32" || process.env.NETCATTY_FIDO_WINDOWS !== "1",
timeout: 180000,
}, async t => {
const dir = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fw-"));
const control = path.join(dir, "presence");
const saved = Object.fromEntries(["NETCATTY_SSH_AGENT_PATH", "SSH_SK_PROVIDER", "NETCATTY_FIDO_SIM_CONTROL"].map(key => [key, process.env[key]]));
const seen = [];
let responsePin = "123456";
let recoveryAction = "cancel";
let acceptedPublicKeys = 0;
let acceptedPasswords = 0;
const clients = new Set();
const authorized = new Map();
const sender = {
id: 2823, isDestroyed: () => false,
send(channel, request) {
if (channel !== "netcatty:fido-prompt-request") return;
seen.push(request);
if (request.notificationOnly) return;
queueMicrotask(() => prompts.handleResponse({ sender }, {
requestId: request.requestId,
response: request.kind === "pin" ? responsePin : recoveryAction,
cancelled: request.kind === "recovery" && recoveryAction === "cancel",
}));
},
};
const { privateKey: hostKey } = generateKeyPairSync("rsa", {
modulusLength: 2048, privateKeyEncoding: { type: "pkcs1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
const server = new Server({ hostKeys: [hostKey] }, client => {
clients.add(client);
client.on("error", () => {});
client.on("close", () => clients.delete(client));
client.on("authentication", ctx => {
if (ctx.method === "publickey") {
const key = authorized.get(ctx.key.data.toString("base64"));
// ssh2's server decoder strips the first string length from signatures.
// SK verification also needs that length, plus the untouched flags/counter.
let signature;
if (ctx.signature && ctx.signature.length >= 5) {
const length = Buffer.alloc(4);
length.writeUInt32BE(ctx.signature.length - 5);
signature = Buffer.concat([length, ctx.signature]);
}
if (key && (!ctx.signature || (signature && key.verify(ctx.blob, signature, ctx.hashAlgo) === true))) {
if (ctx.signature) acceptedPublicKeys += 1;
return ctx.accept();
}
}
if (ctx.method === "password" && ctx.password === "disposable-test-password") {
acceptedPasswords += 1;
return ctx.accept();
}
ctx.reject(["publickey", "password"]);
});
client.on("ready", () => client.on("session", accept => {
const session = accept();
session.on("exec", acceptExec => {
const stream = acceptExec();
stream.end("windows-fido-ok");
});
}));
});
async function connect(generated, password) {
const options = await enhanceAuthOptionsForFido({
hostname: "127.0.0.1", port: server.address().port, username: "netcatty",
privateKey: generated.privateKey, identitiesOnly: true, keyLabel: "Windows test key", password,
}, sender);
const agent = await prepareSystemSshAgentForAuth(options, "[WindowsFido]");
assert.ok(agent, "production auth preparation must return an agent");
const client = new Client();
client.on("error", () => {});
attachFidoAgentRelease(client, agent);
const outcome = new Promise(resolve => {
client.once("ready", () => resolve({ ready: true }));
client.on("error", error => {
// Ordinary agent failures allow the explicitly selected password path.
if (error.code === "ERR_FIDO_CANCELLED" || error.level !== "agent") resolve({ error });
});
client.once("close", () => resolve({ error: new Error("Connection closed") }));
});
client.connect({ host: "127.0.0.1", port: server.address().port, username: "netcatty", agent,
password, authHandler: password ? ["agent", "password"] : ["agent"], readyTimeout: 15000 });
return { client, outcome, close() { client.end(); agent._releaseNetcattyFidoAgent?.(); } };
}
try {
assert.ok(process.env.NETCATTY_WINDOWS_SSH_AGENT, "Windows OpenSSH agent path is required");
assert.ok(process.env.NETCATTY_WINDOWS_SSH_KEYGEN, "Windows OpenSSH keygen path is required");
process.env.NETCATTY_SSH_AGENT_PATH = process.env.NETCATTY_WINDOWS_SSH_AGENT;
process.env.SSH_SK_PROVIDER = "/usr/lib/netcatty-fido-test-provider.dll";
process.env.NETCATTY_FIDO_SIM_CONTROL = control.replace(/\\/g, "/");
server.listen(0, "127.0.0.1");
await once(server, "listening");
await t.test("PIN helper exchanges a response over a real Windows named pipe", async () => {
const env = askpass.buildFidoAskpassEnv({ resolveWebContents: () => sender });
const artifacts = askpass.ensureFidoAskpass();
assert.match(artifacts.socketPath, /^\\\\\.\\pipe\\/);
await pause(50);
const result = await run(process.env.ComSpec || "cmd.exe", ["/d", "/s", "/c",
`""${artifacts.wrapperPath}" "Enter PIN for authenticator:""`], {
env: { ...process.env, ...env }, timeout: 10000, windowsHide: true,
windowsVerbatimArguments: true,
});
assert.equal(result.stdout.trim(), "123456");
askpass.releaseFidoAskpassLease(env.NETCATTY_FIDO_ASKPASS_LEASE);
});
for (const type of ["ED25519-SK", "ECDSA-SK"]) {
fs.rmSync(control, { force: true });
const generated = await generateFidoSshKeyPair({
type, verifyRequired: true, resolveWebContents: () => sender,
sshKeygenPath: process.env.NETCATTY_WINDOWS_SSH_KEYGEN,
});
assert.equal(generated.success, true, generated.error);
const key = utils.parseKey(generated.publicKey);
assert.ok(!(key instanceof Error), String(key));
authorized.set(key.getPublicSSH().toString("base64"), key);
await t.test(`${type}: PIN, physical-presence gate, signed login and command`, async () => {
const before = seen.length;
fs.rmSync(`${control}.waiting`, { force: true });
fs.writeFileSync(control, "waiting");
const connection = await connect(generated);
let settled = false;
connection.outcome.then(() => { settled = true; });
try {
await until(() => fs.existsSync(`${control}.waiting`));
assert.equal(settled, false, "PIN submission alone must not finish login");
assert.ok(seen.slice(before).some(request => request.kind === "pin"));
fs.writeFileSync(control, "touch");
const result = await connection.outcome;
assert.equal(result.ready, true, result.error?.message);
const output = await new Promise((resolve, reject) => connection.client.exec("probe", (error, stream) => {
if (error) return reject(error);
let text = "";
stream.on("data", chunk => { text += chunk; });
stream.on("close", () => resolve(text));
}));
assert.equal(output, "windows-fido-ok");
} finally { connection.close(); }
});
for (const fallback of [false, true]) {
await t.test(`${type}: missing device ${fallback ? "allows explicit password fallback" : "cancel never uses saved password"}`, async () => {
const before = seen.length;
const beforePasswords = acceptedPasswords;
recoveryAction = fallback ? "fallback" : "cancel";
fs.writeFileSync(control, "unplugged");
const connection = await connect(generated, "disposable-test-password");
try {
const result = await connection.outcome;
assert.equal(Boolean(result.ready), fallback);
const recovery = seen.slice(before).find(request => request.kind === "recovery");
assert.ok(recovery);
assert.equal(recovery.reason, "failed", "Windows has no helper error observer yet");
assert.equal(recovery.allowPasswordFallback, true);
assert.equal(acceptedPasswords - beforePasswords, Number(fallback));
if (!fallback) assert.equal(result.error?.code, "ERR_FIDO_CANCELLED");
} finally { connection.close(); recoveryAction = "cancel"; }
});
}
await t.test(`${type}: incorrect PIN cannot authenticate`, async () => {
fs.writeFileSync(control, "touch");
responsePin = "incorrect-test-pin";
const connection = await connect(generated);
try {
const result = await connection.outcome;
assert.equal(result.ready, undefined);
assert.equal(result.error?.code, "ERR_FIDO_CANCELLED");
} finally { connection.close(); responsePin = "123456"; }
});
}
assert.equal(acceptedPublicKeys, 2, "server must verify both actual SK signatures");
assert.equal(acceptedPasswords, 2, "only explicit fallback may use a password");
} finally {
for (const client of clients) client.end();
await new Promise(resolve => server.close(resolve));
shutdownFidoAgentSubsystem();
askpass.shutdownFidoAskpass();
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
fs.rmSync(dir, { recursive: true, force: true });
}
});
+8
View File
@@ -0,0 +1,8 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends openssh-server \
&& rm -rf /var/lib/apt/lists/* \
&& useradd -m -s /bin/sh netcatty && passwd -d netcatty \
&& mkdir -p /run/sshd /fixtures && rm -f /etc/ssh/ssh_host_*
COPY sshd_config /etc/ssh/sshd_config
EXPOSE 2222
CMD ["sh", "-c", "ssh-keygen -A && exec /usr/sbin/sshd -D -e"]
+13
View File
@@ -0,0 +1,13 @@
Port 2222
ListenAddress 0.0.0.0
HostKey /etc/ssh/ssh_host_ed25519_key
AuthorizedKeysFile /fixtures/authorized_keys
StrictModes no
UsePAM no
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
AllowUsers netcatty
AllowTcpForwarding yes
LogLevel VERBOSE
Subsystem sftp internal-sftp
+145
View File
@@ -0,0 +1,145 @@
/* Test-only OpenSSH SK provider. Software keys here NEVER protect real accounts.
* Implements the OpenSSH sk-api.h contract using OpenSSL, without USB hardware.
*/
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <openssl/evp.h>
#include <openssl/ec.h>
#include <openssl/ecdsa.h>
#include <openssl/sha.h>
#include <openssl/x509.h>
#include "sk-api.h"
uint32_t sk_api_version(void) { return SSH_SK_VERSION_MAJOR; }
static int check_pin(uint8_t flags, const char *pin) {
if ((flags & SSH_SK_USER_VERIFICATION_REQD)
&& (pin == NULL || strcmp(pin, "123456") != 0))
return SSH_SK_ERR_PIN_REQUIRED;
return 0;
}
static int check_device(void) {
const char *control = getenv("NETCATTY_FIDO_SIM_CONTROL");
if (!control) return 0;
char action[32] = {0};
FILE *input = fopen(control, "r");
if (input) { (void)fgets(action, sizeof(action), input); fclose(input); }
if (!strncmp(action, "unplugged", 9)) return SSH_SK_ERR_DEVICE_NOT_FOUND;
if (!strncmp(action, "unsupported", 11)) return SSH_SK_ERR_UNSUPPORTED;
return 0;
}
/* Optional, test-only physical-presence gate. The host test changes this file
* to emulate a touch/unplug; a renderer response cannot bypass this gate. */
static int await_touch(uint8_t *flags) {
const char *control = getenv("NETCATTY_FIDO_SIM_CONTROL");
if (!control) return 0;
char marker[4096];
snprintf(marker, sizeof(marker), "%s.waiting", control);
FILE *ready = fopen(marker, "w");
if (ready) fclose(ready);
for (int i = 0; i < 150; i++) {
char action[32] = {0};
FILE *input = fopen(control, "r");
if (input) { (void)fgets(action, sizeof(action), input); fclose(input); }
if (!strncmp(action, "unplugged", 9)) return SSH_SK_ERR_DEVICE_NOT_FOUND;
if (!strncmp(action, "no-up", 5)) { *flags &= ~SSH_SK_USER_PRESENCE_REQD; return 0; }
if (!strncmp(action, "no-uv", 5)) { *flags &= ~SSH_SK_USER_VERIFICATION_REQD; return 0; }
if (!strncmp(action, "touch", 5)) return 0;
usleep(20000);
}
return SSH_SK_ERR_GENERAL;
}
int sk_enroll(uint32_t alg, const uint8_t *challenge, size_t challenge_len,
const char *application, uint8_t flags, const char *pin,
struct sk_option **options, struct sk_enroll_response **result) {
(void)challenge; (void)challenge_len; (void)application; (void)options;
int status = check_pin(flags, pin);
if (status) return status;
EVP_PKEY *key = NULL;
if (alg == SSH_SK_ED25519) key = EVP_PKEY_Q_keygen(NULL, NULL, "ED25519");
else if (alg == SSH_SK_ECDSA) key = EVP_PKEY_Q_keygen(NULL, NULL, "EC", "P-256");
if (!key) return SSH_SK_ERR_UNSUPPORTED;
struct sk_enroll_response *response = calloc(1, sizeof(*response));
response->flags = flags;
if (alg == SSH_SK_ED25519) {
response->public_key_len = 32;
response->public_key = malloc(32);
EVP_PKEY_get_raw_public_key(key, response->public_key, &response->public_key_len);
} else {
EVP_PKEY_get_octet_string_param(key, "pub", NULL, 0, &response->public_key_len);
response->public_key = malloc(response->public_key_len);
EVP_PKEY_get_octet_string_param(key, "pub", response->public_key,
response->public_key_len, &response->public_key_len);
}
/* Embed the disposable software private key as the opaque test handle. */
response->key_handle_len = i2d_PrivateKey(key, &response->key_handle);
response->signature = calloc(1, 1);
EVP_PKEY_free(key);
*result = response;
return 0;
}
int sk_sign(uint32_t alg, const uint8_t *data, size_t data_len,
const char *application, const uint8_t *handle, size_t handle_len,
uint8_t flags, const char *pin, struct sk_option **options,
struct sk_sign_response **result) {
(void)options;
int status = check_device();
if (status) return status;
status = check_pin(flags, pin);
if (status) return status;
status = await_touch(&flags);
if (status) return status;
EVP_PKEY *key = d2i_AutoPrivateKey(NULL, &handle, handle_len);
if (!key) return SSH_SK_ERR_GENERAL;
/* OpenSSH signs SHA256(application) || flags || counter || SHA256(data). */
uint8_t message[69] = {0};
SHA256((const uint8_t *)application, strlen(application), message);
message[32] = flags;
message[36] = 1;
SHA256(data, data_len, message + 37);
EVP_MD_CTX *ctx = EVP_MD_CTX_new();
uint8_t signature[128];
size_t signature_len = sizeof(signature);
int ok = EVP_DigestSignInit(ctx, NULL,
alg == SSH_SK_ECDSA ? EVP_sha256() : NULL, NULL, key)
&& EVP_DigestSign(ctx, signature, &signature_len, message, sizeof(message));
EVP_MD_CTX_free(ctx);
EVP_PKEY_free(key);
if (!ok) return SSH_SK_ERR_GENERAL;
struct sk_sign_response *response = calloc(1, sizeof(*response));
response->flags = flags;
response->counter = 1;
if (alg == SSH_SK_ECDSA) {
const uint8_t *ptr = signature;
ECDSA_SIG *sig = d2i_ECDSA_SIG(NULL, &ptr, signature_len);
if (!sig) { free(response); return SSH_SK_ERR_GENERAL; }
const BIGNUM *r, *s;
ECDSA_SIG_get0(sig, &r, &s);
response->sig_r_len = BN_num_bytes(r);
response->sig_s_len = BN_num_bytes(s);
response->sig_r = malloc(response->sig_r_len);
response->sig_s = malloc(response->sig_s_len);
BN_bn2bin(r, response->sig_r);
BN_bn2bin(s, response->sig_s);
ECDSA_SIG_free(sig);
} else {
response->sig_r = malloc(signature_len);
memcpy(response->sig_r, signature, signature_len);
response->sig_r_len = signature_len;
}
*result = response;
return 0;
}
int sk_load_resident_keys(const char *pin, struct sk_option **options,
struct sk_resident_key ***keys, size_t *count) {
(void)pin; (void)options; (void)keys; (void)count;
return SSH_SK_ERR_UNSUPPORTED;
}
+103
View File
@@ -0,0 +1,103 @@
/* $OpenBSD: sk-api.h,v 1.15 2022/07/20 03:29:14 djm Exp $ */
/*
* Copyright (c) 2019 Google LLC
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
#ifndef _SK_API_H
#define _SK_API_H 1
#include <stddef.h>
#ifdef HAVE_STDINT_H
#include <stdint.h>
#endif
/* Flags */
#define SSH_SK_USER_PRESENCE_REQD 0x01
#define SSH_SK_USER_VERIFICATION_REQD 0x04
#define SSH_SK_FORCE_OPERATION 0x10
#define SSH_SK_RESIDENT_KEY 0x20
/* Algs */
#define SSH_SK_ECDSA 0x00
#define SSH_SK_ED25519 0x01
/* Error codes */
#define SSH_SK_ERR_GENERAL -1
#define SSH_SK_ERR_UNSUPPORTED -2
#define SSH_SK_ERR_PIN_REQUIRED -3
#define SSH_SK_ERR_DEVICE_NOT_FOUND -4
#define SSH_SK_ERR_CREDENTIAL_EXISTS -5
struct sk_enroll_response {
uint8_t flags;
uint8_t *public_key;
size_t public_key_len;
uint8_t *key_handle;
size_t key_handle_len;
uint8_t *signature;
size_t signature_len;
uint8_t *attestation_cert;
size_t attestation_cert_len;
uint8_t *authdata;
size_t authdata_len;
};
struct sk_sign_response {
uint8_t flags;
uint32_t counter;
uint8_t *sig_r;
size_t sig_r_len;
uint8_t *sig_s;
size_t sig_s_len;
};
struct sk_resident_key {
uint32_t alg;
size_t slot;
char *application;
struct sk_enroll_response key;
uint8_t flags;
uint8_t *user_id;
size_t user_id_len;
};
struct sk_option {
char *name;
char *value;
uint8_t required;
};
#define SSH_SK_VERSION_MAJOR 0x000a0000 /* current API version */
#define SSH_SK_VERSION_MAJOR_MASK 0xffff0000
/* Return the version of the middleware API */
uint32_t sk_api_version(void);
/* Enroll a U2F key (private key generation) */
int sk_enroll(uint32_t alg, const uint8_t *challenge, size_t challenge_len,
const char *application, uint8_t flags, const char *pin,
struct sk_option **options, struct sk_enroll_response **enroll_response);
/* Sign a challenge */
int sk_sign(uint32_t alg, const uint8_t *data, size_t data_len,
const char *application, const uint8_t *key_handle, size_t key_handle_len,
uint8_t flags, const char *pin, struct sk_option **options,
struct sk_sign_response **sign_response);
/* Enumerate all resident keys */
int sk_load_resident_keys(const char *pin, struct sk_option **options,
struct sk_resident_key ***rks, size_t *nrks);
#endif /* _SK_API_H */
+35 -2
View File
@@ -190,10 +190,18 @@ declare global {
discoverShells?(): Promise<DiscoveredShell[]>;
validatePath?(path: string, type?: 'file' | 'directory' | 'any'): Promise<{ exists: boolean; isFile: boolean; isDirectory: boolean; isExecutable: boolean }>;
generateKeyPair?(options: {
type: 'RSA' | 'ECDSA' | 'ED25519';
type: 'RSA' | 'ECDSA' | 'ED25519' | 'ED25519-SK' | 'ECDSA-SK';
bits?: number;
comment?: string;
}): Promise<{ success: boolean; privateKey?: string; publicKey?: string; error?: string }>;
resident?: boolean;
verifyRequired?: boolean;
}): Promise<{
success: boolean;
privateKey?: string;
publicKey?: string;
keyType?: 'RSA' | 'ECDSA' | 'ED25519' | 'ED25519-SK' | 'ECDSA-SK';
error?: string;
}>;
checkSshAgent?(options?: {
identityAgent?: string;
agentForwarding?: boolean;
@@ -642,6 +650,31 @@ declare global {
onPassphraseAuthFailed?(
cb: (event: { keyPaths: string[]; keyIds?: string[] }) => void
): () => void;
// FIDO2 PIN / touch presence prompts (OpenSSH sk-*)
onFidoPromptRequest?(
cb: (request: {
requestId: string;
kind: "pin" | "touch" | "confirm" | "recovery";
reason?: "device-unavailable" | "timeout" | "pin-failed" | "failed";
allowPasswordFallback?: boolean;
notificationOnly?: boolean;
message?: string;
title?: string;
keyName?: string;
}) => void
): () => void;
respondFidoPrompt?(
requestId: string,
response: string,
cancelled?: boolean
): Promise<{ success: boolean; error?: string }>;
onFidoPromptTimeout?(
cb: (event: { requestId: string }) => void
): () => void;
onFidoPromptCancelled?(
cb: (event: { requestId: string; reason?: string }) => void
): () => void;
}
}