mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6949553a5 | ||
|
|
cf90a88bf8 | ||
|
|
0bdfca08e6 | ||
|
|
6eceffc0fb | ||
|
|
4a9e266c9d | ||
|
|
0d1f759969 | ||
|
|
b1b464f23c | ||
|
|
a598d428f7 | ||
|
|
4d5e16077b | ||
|
|
a2675f2915 |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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': 'Парольная фраза (необязательно)',
|
||||
|
||||
@@ -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(可选)',
|
||||
|
||||
@@ -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(可選)',
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -93,6 +93,7 @@ declare global {
|
||||
}
|
||||
|
||||
interface NetcattySSHOptions {
|
||||
keyLabel?: string;
|
||||
sessionId?: string;
|
||||
hostId?: string;
|
||||
hostLabel?: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user