mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
118c015bd5 | ||
|
|
88caf82e83 | ||
|
|
e3c45097c2 | ||
|
|
46de2023bd | ||
|
|
48f1a1cecf | ||
|
|
2bb52ce6ee |
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 electron/bridges/fidoNativeAgent.test.cjs electron/bridges/fidoAskpass.winpath.test.cjs
|
||||
|
||||
windows-broadcast-ssh:
|
||||
name: windows-broadcast-ssh
|
||||
runs-on: windows-latest
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MonitorHostKeyDialog } from './components/vault/MonitorHostKeyDialog';
|
||||
import { useMonitorRuntime } from './application/state/useMonitorRuntime';
|
||||
import { MonitorRuntimeHost } from './application/app/MonitorRuntimeHost';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
@@ -47,9 +47,9 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
* shell bags; AppSideEffects owns reactive effects + local UI publishing.
|
||||
*/
|
||||
function App() {
|
||||
useMonitorRuntime();
|
||||
return (
|
||||
<AppLocalStateProvider>
|
||||
<MonitorRuntimeHost />
|
||||
<AppSideEffects />
|
||||
<MonitorHostKeyDialog />
|
||||
<AppShell />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useMonitorRuntime } from '../state/useMonitorRuntime';
|
||||
|
||||
/** Keep monitoring state and subscriptions outside the app coordinator. */
|
||||
export function MonitorRuntimeHost() {
|
||||
useMonitorRuntime();
|
||||
return null;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import React, { useState } from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
|
||||
function loadComponent(path: string, name: string, bindings: Record<string, unknown>) {
|
||||
const source = readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
const ast = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const declaration = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === name);
|
||||
assert.ok(declaration, `${name} must exist`);
|
||||
const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), {
|
||||
target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React,
|
||||
});
|
||||
return vm.runInNewContext(`${code}; ${name}`, { React, ...bindings }) as React.ComponentType;
|
||||
}
|
||||
|
||||
test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
|
||||
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let updateMonitor: (() => void) | undefined;
|
||||
let runtimeRenders = 0;
|
||||
let sideEffectRenders = 0;
|
||||
let shellRenders = 0;
|
||||
const useMonitorRuntime = () => {
|
||||
const [, setVersion] = useState(0);
|
||||
updateMonitor = () => setVersion((version) => version + 1);
|
||||
runtimeRenders += 1;
|
||||
};
|
||||
const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime });
|
||||
const App = loadComponent('../../App.tsx', 'App', {
|
||||
useMonitorRuntime,
|
||||
MonitorRuntimeHost,
|
||||
AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
AppSideEffects: () => { sideEffectRenders += 1; return null; },
|
||||
MonitorHostKeyDialog: () => null,
|
||||
AppShell: React.memo(() => { shellRenders += 1; return null; }),
|
||||
});
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(App)); });
|
||||
const initialRuntimeRenders = runtimeRenders;
|
||||
const initialSideEffectRenders = sideEffectRenders;
|
||||
const initialShellRenders = shellRenders;
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await act(async () => updateMonitor!());
|
||||
}
|
||||
assert.equal(runtimeRenders - initialRuntimeRenders, 10, 'monitor updates must still run');
|
||||
assert.equal(sideEffectRenders - initialSideEffectRenders, 0, 'monitor-only state must not render AppSideEffects');
|
||||
assert.equal(shellRenders - initialShellRenders, 0, 'monitor controls must leave the shell stable');
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
env.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
});
|
||||
@@ -36,8 +36,6 @@ export const enScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Library',
|
||||
'scripts.sidePanel.running': 'Running',
|
||||
'scripts.sidePanel.newScript': 'New script',
|
||||
'scripts.sidePanel.viewList': 'List view',
|
||||
'scripts.sidePanel.viewStacked': 'Stacked view',
|
||||
'scripts.running.empty': 'No scripts are running in this session.',
|
||||
'scripts.running.unnamed': 'Untitled script',
|
||||
'scripts.running.status.running': 'Running',
|
||||
|
||||
@@ -439,6 +439,12 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.history.download': 'Download',
|
||||
'cloudSync.history.resolved': 'Resolved',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.clearBusy': 'Finish restoring or recovering the vault before clearing backups.',
|
||||
'cloudSync.localBackups.clear': 'Clear all backups',
|
||||
'cloudSync.localBackups.clearConfirmTitle': 'Clear local backup history?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': 'Permanently delete all local vault backups on this device. Your current hosts, saved credentials and cloud copies will remain. This does not prepare the device for handover. New backups will still be created during normal use.',
|
||||
'cloudSync.localBackups.clearSuccess': 'Local backup history cleared.',
|
||||
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
|
||||
'cloudSync.localBackups.title': 'Local Backup History',
|
||||
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
|
||||
@@ -631,6 +637,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)',
|
||||
|
||||
@@ -36,8 +36,6 @@ export const esScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Biblioteca',
|
||||
'scripts.sidePanel.running': 'En ejecución',
|
||||
'scripts.sidePanel.newScript': 'Nuevo script',
|
||||
'scripts.sidePanel.viewList': 'Vista de lista',
|
||||
'scripts.sidePanel.viewStacked': 'Vista apilada',
|
||||
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
|
||||
'scripts.running.unnamed': 'Script sin título',
|
||||
'scripts.running.status.running': 'En ejecución',
|
||||
|
||||
@@ -438,6 +438,12 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.history.download': 'Descarga',
|
||||
'cloudSync.history.resolved': 'Resuelto',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.clearBusy': 'Finaliza la restauración o recuperación de la bóveda antes de eliminar las copias.',
|
||||
'cloudSync.localBackups.clear': 'Eliminar todas las copias',
|
||||
'cloudSync.localBackups.clearConfirmTitle': '¿Eliminar el historial de copias locales?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': 'Elimina permanentemente todas las copias locales de la bóveda en este dispositivo. Los hosts actuales, las credenciales guardadas y las copias en la nube se conservan. Esto no prepara el dispositivo para entregarlo a otra persona. Se seguirán creando nuevas copias durante el uso normal.',
|
||||
'cloudSync.localBackups.clearSuccess': 'Historial de copias locales eliminado.',
|
||||
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
|
||||
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
|
||||
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
|
||||
|
||||
@@ -36,8 +36,6 @@ export const ruScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Библиотека',
|
||||
'scripts.sidePanel.running': 'Выполняются',
|
||||
'scripts.sidePanel.newScript': 'Новый скрипт',
|
||||
'scripts.sidePanel.viewList': 'Список',
|
||||
'scripts.sidePanel.viewStacked': 'Плитка',
|
||||
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
|
||||
'scripts.running.unnamed': 'Скрипт без названия',
|
||||
'scripts.running.status.running': 'Выполняется',
|
||||
|
||||
@@ -457,6 +457,12 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.history.download': 'Скачивание',
|
||||
'cloudSync.history.resolved': 'Разрешено',
|
||||
'cloudSync.history.error': 'Ошибка',
|
||||
'cloudSync.localBackups.clearBusy': 'Завершите восстановление хранилища перед удалением резервных копий.',
|
||||
'cloudSync.localBackups.clear': 'Удалить все копии',
|
||||
'cloudSync.localBackups.clearConfirmTitle': 'Удалить историю локальных копий?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': 'Все локальные резервные копии хранилища на этом устройстве будут удалены безвозвратно. Текущие хосты, сохранённые учётные данные и облачные копии останутся. Это не очистка устройства для передачи другому человеку. При обычном использовании новые копии будут создаваться по-прежнему.',
|
||||
'cloudSync.localBackups.clearSuccess': 'История локальных копий удалена.',
|
||||
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
|
||||
'cloudSync.localBackups.title': 'История локальных резервных копий',
|
||||
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
|
||||
@@ -646,6 +652,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': 'Парольная фраза (необязательно)',
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
test('scripts side panel view-toggle copy exists in every locale', () => {
|
||||
const KEYS = ['scripts.sidePanel.viewList', 'scripts.sidePanel.viewStacked'];
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !(messages as Record<string, string>)[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`);
|
||||
}
|
||||
});
|
||||
@@ -36,8 +36,6 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.sidePanel.library': '脚本库',
|
||||
'scripts.sidePanel.running': '运行中',
|
||||
'scripts.sidePanel.newScript': '新建脚本',
|
||||
'scripts.sidePanel.viewList': '列表视图',
|
||||
'scripts.sidePanel.viewStacked': '堆叠视图',
|
||||
'scripts.running.empty': '当前会话没有正在运行的脚本。',
|
||||
'scripts.running.unnamed': '未命名脚本',
|
||||
'scripts.running.status.running': '运行中',
|
||||
|
||||
@@ -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(可选)',
|
||||
|
||||
@@ -842,6 +842,12 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.history.download': '下载',
|
||||
'cloudSync.history.resolved': '已解决',
|
||||
'cloudSync.history.error': '错误',
|
||||
'cloudSync.localBackups.clearBusy': '请先完成资料恢复或处理中断的恢复,再清空备份。',
|
||||
'cloudSync.localBackups.clear': '清空全部备份',
|
||||
'cloudSync.localBackups.clearConfirmTitle': '清空本地备份历史?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': '将永久删除此设备上全部主机库的历史备份。当前主机、保存的凭据和云端副本仍会保留,这不代表设备已完成交接清理。以后正常使用时仍会生成新备份。',
|
||||
'cloudSync.localBackups.clearSuccess': '已清空本地备份历史。',
|
||||
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
|
||||
'cloudSync.localBackups.title': '本地备份历史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
|
||||
'cloudSync.localBackups.retentionTitle': '备份保留数量',
|
||||
|
||||
@@ -36,8 +36,6 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.sidePanel.library': '指令碼庫',
|
||||
'scripts.sidePanel.running': '執行中',
|
||||
'scripts.sidePanel.newScript': '新增指令碼',
|
||||
'scripts.sidePanel.viewList': '列表檢視',
|
||||
'scripts.sidePanel.viewStacked': '堆疊檢視',
|
||||
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
|
||||
'scripts.running.unnamed': '未命名指令碼',
|
||||
'scripts.running.status.running': '執行中',
|
||||
|
||||
@@ -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(可選)',
|
||||
|
||||
@@ -842,6 +842,12 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.history.download': '下載',
|
||||
'cloudSync.history.resolved': '已解決',
|
||||
'cloudSync.history.error': '錯誤',
|
||||
'cloudSync.localBackups.clearBusy': '請先完成資料還原或處理中斷的還原,再清空備份。',
|
||||
'cloudSync.localBackups.clear': '清空全部備份',
|
||||
'cloudSync.localBackups.clearConfirmTitle': '清空本機備份歷史?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': '將永久刪除此裝置上全部主機庫的歷史備份。目前的主機、儲存的憑證和雲端副本仍會保留,這不代表裝置已完成交接清理。日後正常使用時仍會產生新備份。',
|
||||
'cloudSync.localBackups.clearSuccess': '已清空本機備份歷史。',
|
||||
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
|
||||
'cloudSync.localBackups.title': '本機備份歷史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
|
||||
'cloudSync.localBackups.retentionTitle': '備份保留數量',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
|
||||
import type { SyncPayload } from '../domain/sync.ts';
|
||||
|
||||
@@ -132,3 +132,89 @@ for (const localRestore of [false, true]) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function installBackupLocks(t: TestContext) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||
let readers = 0;
|
||||
let writer: Promise<void> | null = null;
|
||||
const locks = {
|
||||
async request(_name: string, options: { mode: string; ifAvailable?: boolean }, callback: (lock: object | null) => Promise<unknown>) {
|
||||
if (options.mode === 'shared') {
|
||||
if (writer) await writer;
|
||||
readers += 1;
|
||||
try { return await callback({}); } finally { readers -= 1; }
|
||||
}
|
||||
assert.equal(options.ifAvailable, true, 'cleanup must refuse rather than queue behind a restore');
|
||||
if (readers || writer) return callback(null);
|
||||
let release!: () => void;
|
||||
writer = new Promise<void>((resolve) => { release = resolve; });
|
||||
try { return await callback({}); } finally { writer = null; release(); }
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { locks } });
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'navigator', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'navigator');
|
||||
});
|
||||
}
|
||||
|
||||
function installClearBridge(t: TestContext, clearVaultBackups: () => Promise<unknown>) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: { clearVaultBackups } } });
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
}
|
||||
|
||||
test('cleanup refuses while another restore is applying, including nested restore helpers', async (t) => {
|
||||
installBackupLocks(t);
|
||||
const { clearLocalVaultBackups, withRestoreBarrier, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
|
||||
let clears = 0;
|
||||
installClearBridge(t, async () => { clears += 1; });
|
||||
let finish!: () => void;
|
||||
let entered!: () => void;
|
||||
const active = new Promise<void>((resolve) => { entered = resolve; });
|
||||
const gate = new Promise<void>((resolve) => { finish = resolve; });
|
||||
const restoring = withRestoreBarrier(() => withRestoreBarrier(async () => { entered(); await gate; }));
|
||||
await active;
|
||||
try {
|
||||
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
|
||||
assert.equal(clears, 0);
|
||||
} finally {
|
||||
finish();
|
||||
await restoring;
|
||||
}
|
||||
await clearLocalVaultBackups();
|
||||
assert.equal(clears, 1);
|
||||
});
|
||||
|
||||
test('restore waits for an in-progress cleanup before reading or applying data', async (t) => {
|
||||
installBackupLocks(t);
|
||||
const { clearLocalVaultBackups, withRestoreBarrier } = await import('./localVaultBackups.ts');
|
||||
let finish!: () => void;
|
||||
let entered!: () => void;
|
||||
const active = new Promise<void>((resolve) => { entered = resolve; });
|
||||
const gate = new Promise<void>((resolve) => { finish = resolve; });
|
||||
installClearBridge(t, async () => { entered(); await gate; });
|
||||
const clearing = clearLocalVaultBackups();
|
||||
await active;
|
||||
let applied = false;
|
||||
const restoring = withRestoreBarrier(async () => { applied = true; });
|
||||
await Promise.resolve();
|
||||
assert.equal(applied, false);
|
||||
finish();
|
||||
await Promise.all([clearing, restoring]);
|
||||
assert.equal(applied, true);
|
||||
});
|
||||
|
||||
test('cleanup retains recovery backups after an interrupted protected apply', async (t) => {
|
||||
installBackupLocks(t);
|
||||
const { clearLocalVaultBackups, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
|
||||
const { STORAGE_KEY_VAULT_APPLY_IN_PROGRESS } = await import('../infrastructure/config/storageKeys.ts');
|
||||
let clears = 0;
|
||||
installClearBridge(t, async () => { clears += 1; });
|
||||
localStorageValues.set(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS, JSON.stringify({ startedAt: Date.now(), protectiveBackupId: 'recovery' }));
|
||||
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
|
||||
assert.equal(clears, 0);
|
||||
});
|
||||
@@ -10,6 +10,15 @@ import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
import { hasMeaningfulSyncData } from './syncPayload';
|
||||
|
||||
const BACKUP_APPLY_LOCK = 'netcatty:vault-backup-apply';
|
||||
|
||||
export class LocalVaultBackupBusyError extends Error {
|
||||
constructor() {
|
||||
super('Finish restoring or recovering the vault before clearing backups.');
|
||||
this.name = 'LocalVaultBackupBusyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the current sync data version (the integer that increments
|
||||
* on each successful cloud sync). Returns undefined when the value is
|
||||
@@ -86,6 +95,16 @@ export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCou
|
||||
await bridge?.trimVaultBackups?.({ maxCount });
|
||||
}
|
||||
|
||||
export async function clearLocalVaultBackups(): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
|
||||
if (!bridge?.clearVaultBackups || !locks) throw new Error('Local backup cleanup is unavailable.');
|
||||
await locks.request(BACKUP_APPLY_LOCK, { mode: 'exclusive', ifAvailable: true }, async (lock) => {
|
||||
if (!lock || readInterruptedVaultApply()) throw new LocalVaultBackupBusyError();
|
||||
await bridge.clearVaultBackups!();
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLocalVaultBackupCapabilities(): Promise<{
|
||||
encryptionAvailable: boolean;
|
||||
}> {
|
||||
@@ -265,6 +284,15 @@ export async function withRestoreBarrier<T>(
|
||||
task: () => Promise<T>,
|
||||
holdMs: number = RESTORE_BARRIER_HOLD_MS,
|
||||
): Promise<T> {
|
||||
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
|
||||
// Shared locks allow existing nested restore helpers. Cleanup never queues
|
||||
// an exclusive lock, so a nested helper cannot wait behind its own cleanup.
|
||||
return locks
|
||||
? locks.request(BACKUP_APPLY_LOCK, { mode: 'shared' }, () => runWithRestoreBarrier(task, holdMs))
|
||||
: runWithRestoreBarrier(task, holdMs);
|
||||
}
|
||||
|
||||
async function runWithRestoreBarrier<T>(task: () => Promise<T>, holdMs: number): Promise<T> {
|
||||
const writeDeadline = () => {
|
||||
try {
|
||||
localStorageAdapter.writeNumber(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
type LocalVaultBackupPreview,
|
||||
clearLocalVaultBackups,
|
||||
getLocalVaultBackupCapabilities,
|
||||
getLocalVaultBackupMaxCount,
|
||||
listLocalVaultBackups,
|
||||
@@ -20,13 +21,15 @@ export function useLocalVaultBackups() {
|
||||
// a destructive action that might later be disabled.
|
||||
const [encryptionAvailable, setEncryptionAvailable] = useState<boolean | null>(null);
|
||||
|
||||
const refreshSequence = useRef(0);
|
||||
const refreshBackups = useCallback(async () => {
|
||||
const sequence = ++refreshSequence.current;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const next = await listLocalVaultBackups();
|
||||
setBackups(next);
|
||||
if (sequence === refreshSequence.current) setBackups(next);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (sequence === refreshSequence.current) setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -76,6 +79,14 @@ export function useLocalVaultBackups() {
|
||||
return sanitized;
|
||||
}, [refreshBackups]);
|
||||
|
||||
const clearBackups = useCallback(async () => {
|
||||
try {
|
||||
await clearLocalVaultBackups();
|
||||
} finally {
|
||||
await refreshBackups();
|
||||
}
|
||||
}, [refreshBackups]);
|
||||
|
||||
const openBackupDirectory = useCallback(async () => {
|
||||
await openLocalVaultBackupDir();
|
||||
}, []);
|
||||
@@ -86,6 +97,7 @@ export function useLocalVaultBackups() {
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
clearBackups,
|
||||
readBackup: readLocalVaultBackup,
|
||||
setMaxBackups: updateMaxBackups,
|
||||
openBackupDirectory,
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
parseScriptsViewMode,
|
||||
SCRIPTS_VIEW_MODE_STORAGE_KEY,
|
||||
} from "./useScriptsViewMode.ts";
|
||||
|
||||
test("scripts view mode parser only accepts 'stacked'", () => {
|
||||
assert.equal(parseScriptsViewMode("stacked"), "stacked");
|
||||
assert.equal(parseScriptsViewMode("list"), "list");
|
||||
assert.equal(parseScriptsViewMode("grid"), "list");
|
||||
assert.equal(parseScriptsViewMode(null), "list");
|
||||
assert.equal(parseScriptsViewMode(""), "list");
|
||||
});
|
||||
|
||||
test("scripts view mode hook persists the chosen mode", () => {
|
||||
const source = readFileSync(new URL("./useScriptsViewMode.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /useStoredString/);
|
||||
// The shared hook owns persistence and same-/cross-window subscriptions.
|
||||
assert.match(
|
||||
source,
|
||||
/useStoredString\(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode\)/,
|
||||
);
|
||||
assert.equal(SCRIPTS_VIEW_MODE_STORAGE_KEY.startsWith("netcatty:"), true);
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW } from "../../infrastructure/config/storageKeys";
|
||||
import { useStoredString } from "./useStoredString";
|
||||
|
||||
/** View modes for the scripts side panel library:
|
||||
* 'list' (one snippet per row, tree of packages) and 'stacked' (compact
|
||||
* wrap-around chips). */
|
||||
export type ScriptsViewMode = "list" | "stacked";
|
||||
|
||||
export const SCRIPTS_VIEW_MODE_STORAGE_KEY = STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW;
|
||||
|
||||
/** Pure resolver so callers and tests share one validation rule. */
|
||||
export const parseScriptsViewMode = (value: string | null): ScriptsViewMode =>
|
||||
value === "stacked" ? "stacked" : "list";
|
||||
|
||||
const isScriptsViewMode = (value: string | null): value is ScriptsViewMode =>
|
||||
value === "list" || value === "stacked";
|
||||
|
||||
export const useScriptsViewMode = () =>
|
||||
useStoredString(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode);
|
||||
@@ -1,59 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { SCRIPTS_VIEW_MODE_STORAGE_KEY, useScriptsViewMode } from "./useScriptsViewMode.ts";
|
||||
|
||||
test("script view changes follow native storage events from another window", async (t) => {
|
||||
const windowA = new EventTarget();
|
||||
const windowB = new EventTarget();
|
||||
let activeWindow = windowA;
|
||||
const data = new Map<string, string>();
|
||||
const values = {
|
||||
localStorage: { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); } },
|
||||
addEventListener: (type: string, listener: EventListener) => activeWindow.addEventListener(type, listener),
|
||||
removeEventListener: (type: string, listener: EventListener) => activeWindow.removeEventListener(type, listener),
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
}
|
||||
let a!: ReturnType<typeof useScriptsViewMode>;
|
||||
let b!: ReturnType<typeof useScriptsViewMode>;
|
||||
function FirstWindow() { a = useScriptsViewMode(); return null; }
|
||||
function SecondWindow() { b = useScriptsViewMode(); return null; }
|
||||
let first!: ReactTestRenderer;
|
||||
let second!: ReactTestRenderer;
|
||||
t.after(async () => {
|
||||
activeWindow = windowA; await act(async () => first?.unmount());
|
||||
activeWindow = windowB; await act(async () => second?.unmount());
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
});
|
||||
activeWindow = windowA; await act(async () => { first = create(React.createElement(FirstWindow)); });
|
||||
activeWindow = windowB; await act(async () => { second = create(React.createElement(SecondWindow)); });
|
||||
assert.equal(a[0], "list"); assert.equal(b[0], "list");
|
||||
// BrowserWindow A writes shared storage. Electron emits the native event
|
||||
// in B, not an adapter CustomEvent from A's independent JS environment.
|
||||
activeWindow = windowA; await act(async () => a[1]("stacked"));
|
||||
assert.equal(data.get(SCRIPTS_VIEW_MODE_STORAGE_KEY), "stacked");
|
||||
assert.equal(b[0], "list");
|
||||
const notify = async (target: EventTarget, key = SCRIPTS_VIEW_MODE_STORAGE_KEY) => {
|
||||
const event = new Event("storage");
|
||||
Object.defineProperty(event, "key", { value: key });
|
||||
await act(async () => { target.dispatchEvent(event); });
|
||||
};
|
||||
activeWindow = windowB; await notify(windowB);
|
||||
assert.equal(b[0], "stacked");
|
||||
await act(async () => b[1]("list"));
|
||||
assert.equal(a[0], "stacked");
|
||||
activeWindow = windowA; await notify(windowA);
|
||||
assert.equal(a[0], "list");
|
||||
data.set(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
|
||||
await notify(windowA, "unrelated");
|
||||
assert.equal(a[0], "list", "unrelated preferences must not trigger an update");
|
||||
});
|
||||
@@ -1,22 +1,48 @@
|
||||
/** Serialized Web Locks fixture for tests with a simulated browser window. */
|
||||
/** Web Locks fixture for tests with a simulated browser window. */
|
||||
export function installBrowserLockFixture(): void {
|
||||
const tails = new Map<string, Promise<void>>();
|
||||
const states = new Map<string, {
|
||||
readers: number;
|
||||
writer: boolean;
|
||||
pending: Array<{ mode: LockMode; grant: () => void }>;
|
||||
}>();
|
||||
const locks = {
|
||||
async request<T>(name: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = tails.get(name) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const tail = previous.then(() => gate);
|
||||
tails.set(name, tail);
|
||||
await previous;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
release();
|
||||
if (tails.get(name) === tail) tails.delete(name);
|
||||
async request<T>(
|
||||
name: string,
|
||||
optionsOrTask: LockOptions | ((lock: Pick<Lock, "name" | "mode"> | null) => T | Promise<T>),
|
||||
callback?: (lock: Pick<Lock, "name" | "mode"> | null) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
const options = typeof optionsOrTask === "function" ? {} : optionsOrTask;
|
||||
const task = typeof optionsOrTask === "function" ? optionsOrTask : callback!;
|
||||
const mode = options.mode ?? "exclusive";
|
||||
const state = states.get(name) ?? { readers: 0, writer: false, pending: [] };
|
||||
states.set(name, state);
|
||||
if (options.ifAvailable && (state.writer || state.pending.length || (mode === "exclusive" && state.readers))) {
|
||||
return task(null);
|
||||
}
|
||||
const drain = () => {
|
||||
while (!state.writer && state.pending.length) {
|
||||
const next = state.pending[0];
|
||||
if (next.mode === "exclusive" && state.readers) return;
|
||||
state.pending.shift();
|
||||
if (next.mode === "exclusive") state.writer = true;
|
||||
else state.readers += 1;
|
||||
next.grant();
|
||||
}
|
||||
};
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
state.pending.push({ mode, grant: () => {
|
||||
void (async () => {
|
||||
try {
|
||||
return await task({ name, mode });
|
||||
} finally {
|
||||
if (mode === "exclusive") state.writer = false;
|
||||
else state.readers -= 1;
|
||||
drain();
|
||||
}
|
||||
})().then(resolve, reject);
|
||||
} });
|
||||
drain();
|
||||
});
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from './test-support/renderReactDom';
|
||||
|
||||
test('backup cleanup confirms, preserves data on cancel, reports errors and refreshes after retry', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const globals = {
|
||||
localStorage: dom.window.localStorage,
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
NodeFilter: dom.window.NodeFilter,
|
||||
HTMLInputElement: dom.window.HTMLInputElement,
|
||||
};
|
||||
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
Object.defineProperty(dom.window.navigator, 'locks', { configurable: true, value: {
|
||||
request: async (_name: string, _options: unknown, callback: (lock: object) => Promise<void>) => callback({}),
|
||||
} });
|
||||
const { LocalBackupsPanel } = await import('./cloud-sync/CloudSyncLocalBackupsPanel');
|
||||
const { toast } = await import('./ui/toast');
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const notices: string[] = [];
|
||||
const success = toast.success;
|
||||
const error = toast.error;
|
||||
toast.success = (message) => { notices.push(`success:${message}`); };
|
||||
toast.error = (message) => { notices.push(`error:${message}`); };
|
||||
let calls = 0;
|
||||
let fail = true;
|
||||
let hasBackup = true;
|
||||
let finish: (() => void) | undefined;
|
||||
let finishProbe: ((result: { encryptionAvailable: boolean }) => void) | undefined;
|
||||
Object.defineProperty(dom.window, 'netcatty', { configurable: true, value: {
|
||||
// Cleanup must remain available even when encrypted backups cannot be read.
|
||||
getVaultBackupCapabilities: () => new Promise((resolve) => { finishProbe = resolve; }),
|
||||
listVaultBackups: async () => hasBackup ? [{
|
||||
id: 'fixture', createdAt: 1, reason: 'before_restore', fingerprint: 'fixture',
|
||||
preview: { hostCount: 1, keyCount: 0, snippetCount: 0, noteCount: 0, identityCount: 0, portForwardingRuleCount: 0 },
|
||||
}] : [],
|
||||
clearVaultBackups: async () => {
|
||||
calls += 1;
|
||||
if (fail) throw new Error('access denied');
|
||||
await new Promise<void>((resolve) => { finish = resolve; });
|
||||
hasBackup = false;
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
} });
|
||||
const click = async (node: Element) => {
|
||||
await dispatchDomEvent(node, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
await flushEffects();
|
||||
};
|
||||
const button = (text: string, scope: ParentNode = dom.document) => {
|
||||
const node = [...scope.querySelectorAll('button')].find((el) => el.textContent === text);
|
||||
assert.ok(node, `button ${text}`);
|
||||
return node as HTMLButtonElement;
|
||||
};
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><LocalBackupsPanel onApplyPayload={() => { throw new Error('must not restore'); }} /></I18nProvider>);
|
||||
await flushEffects();
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
|
||||
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
|
||||
await runWithAct(async () => { finishProbe?.({ encryptionAvailable: false }); });
|
||||
await flushEffects();
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
|
||||
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
|
||||
await click(button('Clear all backups'));
|
||||
let dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
assert.match(dialog.textContent ?? '', /current hosts, saved credentials and cloud copies will remain/);
|
||||
await click(button('Cancel', dialog));
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(hasBackup, true);
|
||||
await click(button('Clear all backups'));
|
||||
dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
await click(button('Clear all backups', dialog));
|
||||
assert.equal(calls, 1);
|
||||
assert.match(notices[0], /^error:/);
|
||||
assert.equal(hasBackup, true);
|
||||
dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
fail = false;
|
||||
await click(button('Clear all backups', dialog));
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(button('Clear all backups', dialog).disabled, true);
|
||||
assert.equal(button('Cancel', dialog).disabled, true);
|
||||
assert.equal(notices.length, 1);
|
||||
await runWithAct(async () => { finish?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
|
||||
assert.match(dom.document.body.textContent ?? '', /No local backups yet/);
|
||||
assert.match(notices[1], /^success:/);
|
||||
} finally {
|
||||
toast.success = success;
|
||||
toast.error = error;
|
||||
await renderer.unmount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
EyeOff,
|
||||
FileText,
|
||||
FolderPlus,
|
||||
PlugZap,
|
||||
Plus,
|
||||
Settings2,
|
||||
Tag,
|
||||
@@ -61,6 +62,7 @@ import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
||||
import { Input } from "./ui/input";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
import {
|
||||
ChainPanel,
|
||||
@@ -1015,16 +1017,39 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSaveHost}
|
||||
aria-label={t("hostDetails.saveAria")}
|
||||
>
|
||||
<Check size={16} />
|
||||
</Button>
|
||||
<>
|
||||
{!pluginProtocolActive && form.protocol !== "telnet" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleStartTest}
|
||||
disabled={!canSaveHost}
|
||||
aria-label={t("hostConnectionTest.title")}
|
||||
>
|
||||
<PlugZap size={16} />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("hostConnectionTest.title")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSaveHost}
|
||||
aria-label={t("hostDetails.saveAria")}
|
||||
>
|
||||
<Check size={16} />
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
@@ -1346,19 +1371,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
</Button>
|
||||
)}
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter className="flex flex-row gap-2">
|
||||
{!pluginProtocolActive && form.protocol !== "telnet" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 px-4"
|
||||
onClick={handleStartTest}
|
||||
disabled={!canSaveHost}
|
||||
>
|
||||
{t("hostConnectionTest.test")}
|
||||
</Button>
|
||||
)}
|
||||
<AsidePanelFooter>
|
||||
<Button
|
||||
className="flex-1 h-10"
|
||||
className="w-full h-10"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSaveHost}
|
||||
>
|
||||
|
||||
@@ -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,73 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
|
||||
import type { Snippet } from "../types";
|
||||
|
||||
test("stacked library continues loading after running a script and returning", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver");
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
|
||||
const observers: Array<{ target: Element | null; intersect: () => void }> = [];
|
||||
class Observer {
|
||||
target: Element | null = null;
|
||||
constructor(private callback: IntersectionObserverCallback) { observers.push(this); }
|
||||
observe(target: Element) { this.target = target; }
|
||||
disconnect() { this.target = null; }
|
||||
intersect = () => {
|
||||
if (this.target?.isConnected) {
|
||||
this.callback([{ target: this.target, isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
|
||||
}
|
||||
};
|
||||
}
|
||||
Object.defineProperty(globalThis, "IntersectionObserver", { configurable: true, value: Observer });
|
||||
const { ScriptsSidePanel } = await import("./ScriptsSidePanel.tsx");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { SCRIPTS_VIEW_MODE_STORAGE_KEY } = await import("../application/state/useScriptsViewMode.ts");
|
||||
env.window.localStorage.setItem(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
if (previous) Object.defineProperty(globalThis, "IntersectionObserver", previous);
|
||||
else Reflect.deleteProperty(globalThis, "IntersectionObserver");
|
||||
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
env.cleanup();
|
||||
});
|
||||
const snippets: Snippet[] = Array.from({ length: 300 }, (_, i) => ({
|
||||
id: `script-${i}`, label: `Script ${String(i).padStart(3, "0")}`, command: "echo ok", kind: "script", package: "",
|
||||
}));
|
||||
const executed: string[] = [];
|
||||
await renderer.render(
|
||||
<I18nProvider locale="en"><ScriptsSidePanel snippets={snippets} packages={[]}
|
||||
onSnippetClick={() => assert.fail("scripts use the existing script execution path")}
|
||||
onRunScript={(snippet) => executed.push(snippet.id)} /></I18nProvider>,
|
||||
);
|
||||
const library = () => env.document.querySelector('[data-scripts-view="stacked"]');
|
||||
const chipCount = () => library()?.querySelectorAll("button").length;
|
||||
const button = (label: string) => {
|
||||
const result = Array.from(env.document.querySelectorAll("button")).find((element) => element.textContent === label);
|
||||
assert.ok(result, `missing button ${label}`);
|
||||
return result;
|
||||
};
|
||||
assert.equal(chipCount(), 120);
|
||||
const firstSentinel = observers.at(-1)?.target;
|
||||
assert.ok(firstSentinel?.isConnected);
|
||||
await dispatchDomEvent(button("Script 000"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.deepEqual(executed, ["script-0"]);
|
||||
assert.equal(library(), null, "execution opens Running");
|
||||
assert.equal(firstSentinel.isConnected, false);
|
||||
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(chipCount(), 120);
|
||||
assert.ok(observers.at(-1)?.target?.isConnected, "the new sentinel must be observed");
|
||||
assert.notEqual(observers.at(-1)?.target, firstSentinel);
|
||||
await runWithAct(() => { observers.at(-1)?.intersect(); });
|
||||
assert.equal(chipCount(), 240);
|
||||
await dispatchDomEvent(button("Running"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(chipCount(), 240, "returning preserves the loaded window");
|
||||
await runWithAct(() => { observers.at(-1)?.intersect(); });
|
||||
assert.equal(chipCount(), 300);
|
||||
assert.ok(button("Script 299"));
|
||||
});
|
||||
@@ -174,31 +174,3 @@ test("scripts side panel does not start a drag from a non-primary pointer", () =
|
||||
assert.match(source, /isNonPrimaryPointer/);
|
||||
assert.match(source, /primaryOnlyDragHandlers/);
|
||||
});
|
||||
|
||||
test("scripts side panel offers a stacked view toggle that persists the choice", () => {
|
||||
assert.match(source, /useScriptsViewMode/);
|
||||
const toggle = source.match(/viewStacked[\s\S]*?\{viewMode === 'stacked' \? <List size=\{14\} \/> : <LayoutGrid size=\{14\} \/>\}/);
|
||||
assert.ok(toggle, "view toggle button should swap list / stacked icons");
|
||||
assert.match(source, /aria-pressed=\{viewMode === 'stacked'\}/);
|
||||
// The chosen mode persists via the shared hook so it survives app restarts.
|
||||
assert.match(source, /useScriptsViewMode\(\)/);
|
||||
assert.match(source, /setViewMode\(viewMode === 'stacked' \? 'list' : 'stacked'\)/);
|
||||
});
|
||||
|
||||
test("scripts side panel stacked view renders wrap-around chips and keeps run behavior", () => {
|
||||
const stacked = source.match(/viewMode === 'stacked' \? \([\s\S]*?\) : \(/);
|
||||
assert.ok(stacked, "stacked branch should exist in the content area");
|
||||
assert.match(stacked[0], /flex flex-wrap/);
|
||||
// Chips reuse the same click handler (multi-select / run / execute) as rows.
|
||||
assert.match(stacked[0], /onClick=\{\(\) => handleSnippetClick\(snippet\)\}/);
|
||||
// Packages stay visible as section headers with their counts.
|
||||
assert.match(stacked[0], /item\.row\.name/);
|
||||
assert.match(stacked[0], /item\.countLabel/);
|
||||
assert.match(source, /const SnippetChip = memo/);
|
||||
// The stacked chip keeps the same context menu contract (edit / delete / run).
|
||||
const chip = source.match(/const SnippetChip = memo[\s\S]*?SnippetChip\.displayName/);
|
||||
assert.ok(chip, "SnippetChip component should exist");
|
||||
assert.match(chip[0], /ContextMenuContent/);
|
||||
assert.match(chip[0], /onRunParallel/);
|
||||
assert.match(chip[0], /onEdit/);
|
||||
});
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
Expand,
|
||||
FolderPlus,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Minimize2,
|
||||
Package,
|
||||
Play,
|
||||
@@ -29,7 +27,6 @@ import {
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
|
||||
import { useScriptsViewMode } from '../application/state/useScriptsViewMode.ts';
|
||||
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
||||
import {
|
||||
collectSnippetPackageTreePaths,
|
||||
@@ -64,12 +61,6 @@ const toolbarIconButtonClass =
|
||||
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
|
||||
|
||||
const SCRIPT_ROW_HEIGHT = 34;
|
||||
// The stacked view renders wrapping chips that cannot use FixedSizeVirtualList,
|
||||
// so cap the initial render and grow lazily as the user scrolls to keep a huge
|
||||
// library (or a broad search) from mounting every chip at once.
|
||||
const STACKED_INITIAL_RENDER_COUNT = 120;
|
||||
const STACKED_RENDER_BATCH = 120;
|
||||
|
||||
const isRootPackagePath = (path: string): boolean => {
|
||||
const body = path.startsWith('/') ? path.slice(1) : path;
|
||||
return body.length > 0 && !body.includes('/');
|
||||
@@ -179,14 +170,10 @@ export function buildScriptsSidePanelRows({
|
||||
snippets,
|
||||
packages,
|
||||
expandedPaths,
|
||||
snippetsBeforeChildren = false,
|
||||
}: {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
expandedPaths: Set<string>;
|
||||
/** Emit each package's own snippets before its sub-packages so flat views
|
||||
* (stacked chips) keep every package's chips attached to its section. */
|
||||
snippetsBeforeChildren?: boolean;
|
||||
}): TreeRow[] {
|
||||
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
|
||||
|
||||
@@ -263,13 +250,6 @@ export function buildScriptsSidePanelRows({
|
||||
});
|
||||
|
||||
if (!isExpanded) return;
|
||||
if (snippetsBeforeChildren) {
|
||||
// Flat stacked view: a package's chips must follow its own section
|
||||
// header, not trail after its nested children's chips.
|
||||
emitSnippets(localSnippets, pkg, depth);
|
||||
children.forEach((child) => walk(child, depth + 1));
|
||||
return;
|
||||
}
|
||||
children.forEach((child) => walk(child, depth + 1));
|
||||
emitSnippets(localSnippets, pkg, depth);
|
||||
};
|
||||
@@ -303,7 +283,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const [subView, setSubView] = useState<'library' | 'running'>('library');
|
||||
const [viewMode, setViewMode] = useScriptsViewMode();
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
|
||||
@@ -314,7 +293,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const [renamingPackagePath, setRenamingPackagePath] = useState('');
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
const [packageError, setPackageError] = useState('');
|
||||
const [stackedRenderLimit, setStackedRenderLimit] = useState(STACKED_INITIAL_RENDER_COUNT);
|
||||
const packageDialogRef = useRef<HTMLDivElement>(null);
|
||||
const packageNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -509,11 +487,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
expandedPaths,
|
||||
// Stacked chips wrap flat, so each package's chips must stay attached
|
||||
// to its own section header.
|
||||
snippetsBeforeChildren: viewMode === 'stacked',
|
||||
});
|
||||
}, [snippets, packages, expandedPaths, searchMatches, isVisible, viewMode]);
|
||||
}, [snippets, packages, expandedPaths, searchMatches, isVisible]);
|
||||
|
||||
type ScriptsListItem =
|
||||
| { key: string; kind: 'search'; snippet: Snippet }
|
||||
@@ -546,48 +521,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
});
|
||||
}, [rows, searchMatches, t, isVisible]);
|
||||
|
||||
const stackedSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Restart the lazy window whenever the underlying result set changes
|
||||
// (new search, package list changes, snippet edits, view-mode switch) — but
|
||||
// NOT on package expand/collapse. Expanding/collapsing only reshapes the
|
||||
// same result set; resetting there would drop the user's lazy-loaded window
|
||||
// and remount the package they just toggled, forcing them to re-scroll.
|
||||
// Keyed by data identity, not by listItems reference, for that reason.
|
||||
// Reset during render — not in an effect — so the first render of a new
|
||||
// result set never slices with the previous, potentially very large window.
|
||||
const stackedResultSetKey = useMemo(() => {
|
||||
if (searchMatches !== null) {
|
||||
return `search:${searchMatches.map((snippet) => snippet.id).join('\u0000')}`;
|
||||
}
|
||||
return `tree:${viewMode}:${packages.join('\u0000')}:${snippets
|
||||
.map((snippet) => snippet.id)
|
||||
.join('\u0000')}`;
|
||||
}, [searchMatches, viewMode, packages, snippets]);
|
||||
|
||||
const [prevResultSetKey, setPrevResultSetKey] = useState(stackedResultSetKey);
|
||||
if (prevResultSetKey !== stackedResultSetKey) {
|
||||
setPrevResultSetKey(stackedResultSetKey);
|
||||
setStackedRenderLimit(STACKED_INITIAL_RENDER_COUNT);
|
||||
}
|
||||
|
||||
const hasMoreStackedItems = viewMode === 'stacked' && stackedRenderLimit < listItems.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (subView !== 'library' || !hasMoreStackedItems) return;
|
||||
const sentinel = stackedSentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
// Re-created per batch: observing always emits an initial intersection
|
||||
// callback, so short result sets that never scroll keep loading.
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setStackedRenderLimit((limit) => limit + STACKED_RENDER_BATCH);
|
||||
}
|
||||
});
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMoreStackedItems, stackedRenderLimit, subView]);
|
||||
|
||||
const handleSnippetClick = useCallback(
|
||||
(snippet: Snippet) => {
|
||||
if (isMultiSelectMode) {
|
||||
@@ -1046,30 +979,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('snippets.searchPlaceholder')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
toolbarIconButtonClass,
|
||||
viewMode === 'stacked' && 'bg-muted/70 text-foreground',
|
||||
)}
|
||||
disabled={!hasAnyContent}
|
||||
aria-label={viewMode === 'stacked'
|
||||
? t('scripts.sidePanel.viewList')
|
||||
: t('scripts.sidePanel.viewStacked')}
|
||||
aria-pressed={viewMode === 'stacked'}
|
||||
onClick={() => setViewMode(viewMode === 'stacked' ? 'list' : 'stacked')}
|
||||
>
|
||||
{viewMode === 'stacked' ? <List size={14} /> : <LayoutGrid size={14} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{viewMode === 'stacked'
|
||||
? t('scripts.sidePanel.viewList')
|
||||
: t('scripts.sidePanel.viewStacked')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -1217,107 +1126,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
) : viewMode === 'stacked' ? (
|
||||
<div
|
||||
className="h-full overflow-auto px-2 py-1.5"
|
||||
data-scripts-view="stacked"
|
||||
>
|
||||
<div className="flex flex-wrap items-center content-start gap-1.5">
|
||||
{listItems.slice(0, stackedRenderLimit).map((item) => {
|
||||
if (item.kind === 'package') {
|
||||
// Mirror the list view's PackageRow context menu so stacked
|
||||
// mode keeps rename/delete reachable without switching views.
|
||||
const canMutatePackages = Boolean(onPackagesChange && onSnippetsChange);
|
||||
const header = (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => togglePackage(item.row.path)}
|
||||
aria-expanded={item.row.isExpanded}
|
||||
className="w-full basis-full flex items-center gap-1.5 pt-1.5 pb-0.5 text-left rounded-sm hover:bg-accent/40 transition-colors"
|
||||
style={{ paddingLeft: 8 + Math.min(item.row.depth, 4) * 10 }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={11}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground transition-transform',
|
||||
item.row.isExpanded && 'rotate-90',
|
||||
!item.row.hasChildren && 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Package size={11} className="shrink-0 text-primary/80" />
|
||||
<span className="min-w-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||
{item.row.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
|
||||
{item.countLabel}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
if (!canMutatePackages) {
|
||||
return header;
|
||||
}
|
||||
return (
|
||||
<ContextMenu key={item.key}>
|
||||
<ContextMenuTrigger asChild>
|
||||
{header}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => openRenamePackageDialog(item.row.path)}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => requestDeletePackage(item.row.path)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
const snippet = item.kind === 'search' ? item.snippet : item.row.snippet;
|
||||
const isScript = isScriptSnippet(snippet);
|
||||
// Search results drop the package headers, so surface each
|
||||
// snippet's package on the chip itself to keep results
|
||||
// distinguishable (labels are not unique across packages).
|
||||
const chipSubtitle = item.kind === 'search'
|
||||
? (snippet.package || t('terminal.toolbar.library'))
|
||||
: undefined;
|
||||
return (
|
||||
<SnippetChip
|
||||
key={item.key}
|
||||
snippet={snippet}
|
||||
subtitle={chipSubtitle}
|
||||
selected={selectedSnippetIds.has(snippet.id)}
|
||||
multiSelect={isMultiSelectMode}
|
||||
onClick={() => handleSnippetClick(snippet)}
|
||||
onEdit={() => handleEditSnippet(snippet)}
|
||||
onDelete={() => handleDeleteSnippet(snippet.id)}
|
||||
onCopyCommand={() => handleCopySnippetCommand(snippet)}
|
||||
copyCommandLabel={t('scripts.actions.copyCommand')}
|
||||
onRunParallel={onRunScriptOnWorkspace
|
||||
? () => onRunScriptOnWorkspace(snippet, 'parallel')
|
||||
: undefined}
|
||||
onRunSequential={isScript && onRunScriptOnWorkspace
|
||||
? () => onRunScriptOnWorkspace(snippet, 'sequential')
|
||||
: undefined}
|
||||
runParallelLabel={isScript
|
||||
? t('scripts.actions.runParallel')
|
||||
: t('scripts.actions.runOnAllTabs')}
|
||||
runSequentialLabel={t('scripts.actions.runSequential')}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasMoreStackedItems ? (
|
||||
<div ref={stackedSentinelRef} className="h-px w-full" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
className="h-full"
|
||||
@@ -1842,117 +1650,5 @@ const SnippetRow = memo<SnippetRowProps>(({
|
||||
));
|
||||
SnippetRow.displayName = 'SnippetRow';
|
||||
|
||||
interface SnippetChipProps {
|
||||
snippet: Snippet;
|
||||
/** Optional package context shown on the chip (used by stacked search
|
||||
* results, where the package headers are dropped). */
|
||||
subtitle?: string;
|
||||
selected?: boolean;
|
||||
multiSelect?: boolean;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onRunParallel?: () => void;
|
||||
onRunSequential?: () => void;
|
||||
onCopyCommand?: () => void;
|
||||
copyCommandLabel?: string;
|
||||
runParallelLabel?: string;
|
||||
runSequentialLabel?: string;
|
||||
editLabel: string;
|
||||
deleteLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact chip for the stacked (wrap) view. Shares the same click → run /
|
||||
* execute and right-click context menu as the list rows; only the layout is
|
||||
* denser. Drag reordering stays list-view-only.
|
||||
*/
|
||||
const SnippetChip = memo<SnippetChipProps>(({
|
||||
snippet,
|
||||
subtitle,
|
||||
selected = false,
|
||||
multiSelect = false,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRunParallel,
|
||||
onRunSequential,
|
||||
onCopyCommand,
|
||||
copyCommandLabel,
|
||||
runParallelLabel,
|
||||
runSequentialLabel,
|
||||
editLabel,
|
||||
deleteLabel,
|
||||
}) => (
|
||||
<ContextMenu>
|
||||
{/* Radix ContextMenuTrigger needs a real DOM element (Tooltip.Root renders
|
||||
none), so mirror SnippetRow and wrap the tooltip in a plain div. */}
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="min-w-0 max-w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={multiSelect ? selected : undefined}
|
||||
className={cn(
|
||||
'h-6 max-w-full min-w-0 px-2 rounded-md border border-border/60 bg-muted/40 hover:bg-accent/60 text-[11px] flex items-center gap-1 transition-colors',
|
||||
selected && 'bg-primary/10 border-primary/40 hover:bg-primary/15',
|
||||
)}
|
||||
>
|
||||
{multiSelect ? (
|
||||
<CheckSquare
|
||||
size={11}
|
||||
className={cn('shrink-0', selected ? 'text-primary' : 'text-muted-foreground/70')}
|
||||
/>
|
||||
) : isScriptSnippet(snippet) ? (
|
||||
<Play size={11} className="shrink-0 text-primary" />
|
||||
) : (
|
||||
<Zap size={11} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{snippet.label}</span>
|
||||
{subtitle ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="shrink-0 text-muted-foreground/50">·</span>
|
||||
<span className="min-w-0 truncate text-[10px] text-muted-foreground/80">
|
||||
{subtitle}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="start">
|
||||
<SnippetCommandTooltipContent label={snippet.label} command={snippet.command} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{onRunParallel ? (
|
||||
<ContextMenuItem onClick={onRunParallel}>
|
||||
<Layers className="mr-2 h-4 w-4" /> {runParallelLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onRunSequential ? (
|
||||
<ContextMenuItem onClick={onRunSequential}>
|
||||
<Layers className="mr-2 h-4 w-4" /> {runSequentialLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onCopyCommand ? (
|
||||
<ContextMenuItem onClick={onCopyCommand}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {copyCommandLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
));
|
||||
SnippetChip.displayName = 'SnippetChip';
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useLocalVaultBackups } from '../../application/state/useLocalVaultBackups';
|
||||
import {
|
||||
LocalVaultBackupBusyError,
|
||||
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
withRestoreBarrier,
|
||||
@@ -29,6 +31,7 @@ import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Input } from '../ui/input';
|
||||
import { ConfirmDialog } from '../ui/confirm-dialog';
|
||||
import { toast } from '../ui/toast';
|
||||
|
||||
// ============================================================================
|
||||
@@ -54,10 +57,13 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
clearBackups,
|
||||
readBackup,
|
||||
setMaxBackups,
|
||||
openBackupDirectory,
|
||||
} = useLocalVaultBackups();
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
|
||||
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
|
||||
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
|
||||
@@ -175,34 +181,34 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const restoreAllowed = restoreDisabledReason === null;
|
||||
// While encryptionAvailable is still `null` we're mid-probe — render the
|
||||
// restore button as disabled so the user never sees a path they can't
|
||||
// actually take (I1 surface). Once resolved, `false` hides the panel body
|
||||
// via the unavailable banner below.
|
||||
const encryptionResolved = encryptionAvailable !== null;
|
||||
const encryptionUsable = encryptionAvailable === true;
|
||||
const performClear = async () => {
|
||||
if (isClearing) return;
|
||||
setIsClearing(true);
|
||||
try {
|
||||
await clearBackups();
|
||||
setShowClearDialog(false);
|
||||
toast.success(t('cloudSync.localBackups.clearSuccess'));
|
||||
} catch (error) {
|
||||
toast.error(t(error instanceof LocalVaultBackupBusyError
|
||||
? 'cloudSync.localBackups.clearBusy'
|
||||
: 'cloudSync.localBackups.clearFailed'), t('sync.toast.errorTitle'));
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// safeStorage probe finished and returned "not available" → disable the
|
||||
// panel entirely; the main process refuses to write in this state (I1).
|
||||
if (encryptionResolved && !encryptionUsable) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="text-sm font-medium">
|
||||
{t('cloudSync.localBackups.unavailableTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.unavailableDesc')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const restoreAllowed = restoreDisabledReason === null && encryptionAvailable === true;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{encryptionAvailable === false && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
|
||||
<div className="text-sm font-medium text-amber-600 dark:text-amber-400">
|
||||
{t('cloudSync.localBackups.unavailableTitle')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t('cloudSync.localBackups.unavailableDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-lg">
|
||||
@@ -224,7 +230,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleSaveMaxBackups()}
|
||||
disabled={isSavingMaxBackups}
|
||||
disabled={isSavingMaxBackups || isClearing || restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
|
||||
@@ -235,7 +241,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!restoreAllowed && (
|
||||
{restoreDisabledReason !== null && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
|
||||
<AlertTriangle size={14} />
|
||||
@@ -248,19 +254,19 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void refreshBackups()}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isClearing}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
|
||||
@@ -275,6 +281,16 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
<FolderOpen size={14} />
|
||||
{t('settings.system.openFolder')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowClearDialog(true)}
|
||||
disabled={isLoading || isClearing || isSavingMaxBackups || restoringBackupId !== null}
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('cloudSync.localBackups.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -339,7 +355,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
// clicks here would interleave destructive
|
||||
// writes and the second run's sentinel-clear
|
||||
// could mask a still-partial first apply.
|
||||
disabled={restoringBackupId !== null}
|
||||
disabled={restoringBackupId !== null || isClearing}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId === backup.id ? (
|
||||
@@ -356,6 +372,17 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showClearDialog}
|
||||
title={t('cloudSync.localBackups.clearConfirmTitle')}
|
||||
message={t('cloudSync.localBackups.clearConfirmDesc')}
|
||||
confirmLabel={t('cloudSync.localBackups.clear')}
|
||||
busy={isClearing}
|
||||
destructive
|
||||
onOpenChange={(open) => { if (!isClearing) setShowClearDialog(open); }}
|
||||
onConfirm={() => void performClear()}
|
||||
/>
|
||||
|
||||
{/* Restore confirmation dialog (I2). Keeps the destructive action
|
||||
gated behind an explicit second click, mirroring the clear-local
|
||||
dialog elsewhere in this screen. */}
|
||||
@@ -416,7 +443,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(null)}
|
||||
disabled={restoringBackupId !== null}
|
||||
disabled={restoringBackupId !== null || isClearing}
|
||||
>
|
||||
{t('cloudSync.localBackups.restoreConfirmCancel')}
|
||||
</Button>
|
||||
@@ -428,7 +455,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
setPendingRestoreBackup(null);
|
||||
await performRestore(target.id);
|
||||
}}
|
||||
disabled={restoringBackupId !== null}
|
||||
disabled={restoringBackupId !== null || isClearing}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId !== null ? (
|
||||
|
||||
@@ -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,172 @@
|
||||
# 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.
|
||||
|
||||
The Linux simulation also removes the optional `.pub` file before strict agent
|
||||
selection, runs two real native OpenSSH clients concurrently against separate
|
||||
caller-bound agent sockets, and checks native SK certificate authentication.
|
||||
It verifies that releasing a second certificate preserves the bare key and
|
||||
the first certificate while removing only the released certificate. Native
|
||||
Mosh/ET authentication uses these sockets so its signatures follow the same
|
||||
window attribution and recovery wrapper as ssh2.
|
||||
|
||||
Identity preparation and removal are serialized per underlying agent socket.
|
||||
Bare-key removal uses `ssh-add -d -k` and certificate removal has its own hold;
|
||||
staging directories remain until all associated removals have completed.
|
||||
The tests also cover partial credential-write failures, socket paths containing
|
||||
spaces, and unknown agent-extension requests followed by normal requests on
|
||||
the same connection.
|
||||
|
||||
## 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 uses a managed temporary path containing spaces and 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,500 @@
|
||||
"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=([^;\r\n]+)/.exec(stdout || "");
|
||||
const pidMatch = /SSH_AGENT_PID=(\d+)/.exec(stdout || "");
|
||||
return {
|
||||
socketPath: sockMatch?.[1]?.trim().replace(/^(['"])(.*)\1$/, "$2") || 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,
|
||||
sshBinaryPath: resolveSshAgentBinary(options.env || process.env, options.platform || process.platform),
|
||||
env: options.env }),
|
||||
owned: shared.owned,
|
||||
generation: shared.generation,
|
||||
sshAddPath: shared.sshAddPath || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isAgentLive() && agentSocket) {
|
||||
refCount += 1;
|
||||
return {
|
||||
socketPath: agentSocket,
|
||||
askpassEnv: buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents,
|
||||
sshBinaryPath: resolveSshAgentBinary(options.env || process.env, options.platform || process.platform),
|
||||
env: options.env }),
|
||||
// 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 platform = options.platform || process.platform;
|
||||
const sshAgent = resolveSshAgentBinary(env, platform);
|
||||
const askpassEnv = buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents,
|
||||
sshBinaryPath: sshAgent, env });
|
||||
const releaseStarterAskpassLease = () => {
|
||||
try {
|
||||
releaseFidoAskpassLease(askpassEnv?.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const sshAddPath = companionSshAddPath(sshAgent);
|
||||
|
||||
// MSYS/Cygwin sockaddr_un still has the Unix 108-byte path limit. The
|
||||
// managed base already identifies Netcatty; keep the socket suffix short.
|
||||
agentDir = path.join(getTempBase(), `fa-${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").replace(/\\/g, "/"),
|
||||
]
|
||||
: [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,305 @@
|
||||
"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("parseAgentStdout preserves spaces in Unix and Windows socket paths", () => {
|
||||
for (const socket of ["/tmp/profile name/agent.sock", "C:\\Users\\First Last\\agent.sock"]) {
|
||||
assert.equal(parseAgentStdout(`SSH_AUTH_SOCK=${socket}; export SSH_AUTH_SOCK;`).socketPath, socket);
|
||||
assert.equal(parseAgentStdout(`SSH_AUTH_SOCK='${socket}'; export SSH_AUTH_SOCK;`).socketPath, socket);
|
||||
}
|
||||
});
|
||||
|
||||
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,537 @@
|
||||
"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.posix.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, platform = process.platform) {
|
||||
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 detachedNotification = notificationOnly && platform === "win32";
|
||||
if (kind === "pin" && platform === "win32") {
|
||||
fidoPromptHandler.cancelFidoPromptRequestsForLease(ownerLeaseId, "pin-requested", true);
|
||||
}
|
||||
const pending = fidoPromptHandler.requestFidoPrompt(sender, {
|
||||
kind,
|
||||
notificationOnly,
|
||||
message: prompt,
|
||||
keyName: signing?.keyName || "",
|
||||
leaseId: ownerLeaseId,
|
||||
signal: detachedNotification
|
||||
? (signing?.attemptController || signing?.controller)?.signal : controller.signal,
|
||||
});
|
||||
if (detachedNotification) {
|
||||
// MSYS cannot reliably terminate a native Node notification helper.
|
||||
// Acknowledge this non-authorizing notification immediately; retain the
|
||||
// hint under the signing attempt (or key-generation lease) instead.
|
||||
void pending;
|
||||
socket.end('{"ok":true,"response":""}\n');
|
||||
return;
|
||||
}
|
||||
const result = await pending;
|
||||
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
|
||||
&& (platform === "win32" || /user presence|touch|tap/i.test(prompt))
|
||||
&& ((signing?.leaseId === ownerLeaseId && !signing.controller.signal.aborted)
|
||||
|| (platform === "win32" && ownerLeaseId))) {
|
||||
// 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);
|
||||
|
||||
const posixWrapperPath = path.join(base, "netcatty-fido-askpass.sh");
|
||||
const shellQuote = value => `'${value.replace(/'/g, "'\\''")}'`;
|
||||
const shellPath = value => process.platform === "win32" ? value.replace(/\\/g, "/") : value;
|
||||
writeSecureFile(posixWrapperPath,
|
||||
`#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${shellQuote(shellPath(process.execPath))} ${shellQuote(shellPath(askpassScriptPath))} "$@"\n`,
|
||||
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 = posixWrapperPath;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function isWindowsPosixSsh(binaryPath, platform = process.platform, env = process.env, exists = fs.existsSync) {
|
||||
if (platform !== "win32" || !binaryPath) return false;
|
||||
const binary = /\.exe$/i.test(binaryPath) ? binaryPath : `${binaryPath}.exe`;
|
||||
const candidates = /[\\/]/.test(binaryPath) ? [binary]
|
||||
: (env.PATH || env.Path || "").split(";").map(dir => path.win32.join(dir, binary));
|
||||
for (const candidate of candidates) {
|
||||
if (!exists(candidate)) continue;
|
||||
const dir = path.win32.dirname(candidate);
|
||||
if (exists(path.win32.join(dir, "msys-2.0.dll")) || exists(path.win32.join(dir, "cygwin1.dll"))) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectFidoAskpassWrapper(wrapperPath, binaryPath, platform = process.platform, env = process.env, exists = fs.existsSync) {
|
||||
// MSYS uses its own shell/shebang execution so spaces in helper paths survive.
|
||||
return isWindowsPosixSsh(binaryPath, platform, env, exists)
|
||||
? wrapperPath.replace(/\.cmd$/i, ".sh").replace(/\\/g, "/") : wrapperPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment for ssh-agent / ssh-add / ssh-keygen so PIN/touch use Netcatty UI.
|
||||
* @param {{ resolveWebContents?: () => import("electron").WebContents|null, sshBinaryPath?: string, env?: NodeJS.ProcessEnv }} [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: selectFidoAskpassWrapper(artifacts.wrapperPath, options.sshBinaryPath, process.platform, options.env),
|
||||
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,
|
||||
selectFidoAskpassWrapper,
|
||||
isWindowsPosixSsh,
|
||||
// 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 'q$-"));
|
||||
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,89 @@
|
||||
"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");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
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"));
|
||||
});
|
||||
|
||||
test("Windows notification helpers exit immediately while hints follow PIN and lease ownership", async () => {
|
||||
class Socket extends EventEmitter {
|
||||
setEncoding() {}
|
||||
end(data) { this.reply = JSON.parse(data); this.emit("close"); }
|
||||
}
|
||||
const sender = { id: 2823, isDestroyed: () => false, send() {} };
|
||||
const env = buildFidoAskpassEnv({ resolveWebContents: () => sender });
|
||||
const leaseId = env.NETCATTY_FIDO_ASKPASS_LEASE;
|
||||
const { handleAskpassClient } = require("./fidoAskpass.cjs");
|
||||
try {
|
||||
const notification = new Socket();
|
||||
handleAskpassClient(notification, "win32");
|
||||
notification.emit("data", `${JSON.stringify({ mode: "none", leaseId, prompt: "Touch security key" })}\n`);
|
||||
assert.equal(notification.reply.ok, true, "native helper must not block device-failure replies");
|
||||
assert.equal(fidoPromptHandler.getRequests().size, 1, "helper exit must not dismiss the independent hint");
|
||||
const pin = new Socket();
|
||||
handleAskpassClient(pin, "win32");
|
||||
pin.emit("data", `${JSON.stringify({ leaseId, prompt: "Enter PIN for authenticator:" })}\n`);
|
||||
const requests = [...fidoPromptHandler.getRequests()];
|
||||
assert.equal(requests.length, 1, "a stale touch hint must not block PIN input");
|
||||
assert.equal(requests[0][1].kind, "pin");
|
||||
fidoPromptHandler.handleResponse({ sender }, { requestId: requests[0][0], response: "fixture-pin" });
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(pin.reply.response, "fixture-pin");
|
||||
const hints = [...fidoPromptHandler.getRequests().values()];
|
||||
assert.equal(hints.length, 1);
|
||||
assert.equal(hints[0].kind, "touch");
|
||||
releaseFidoAskpassLease(leaseId);
|
||||
assert.equal(fidoPromptHandler.getRequests().size, 0);
|
||||
} finally { releaseFidoAskpassLease(leaseId); shutdownFidoAskpass(); }
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
resolveFidoAskpassSocketPath,
|
||||
isWindowsNamedPipePath,
|
||||
selectFidoAskpassWrapper,
|
||||
} = 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), process.platform === "win32");
|
||||
});
|
||||
|
||||
test("Windows MSYS helpers use a shell wrapper while native OpenSSH keeps its command wrapper", () => {
|
||||
const wrapper = "C:\\Users\\First Last\\askpass.cmd";
|
||||
const msys = "C:\\msys64\\usr\\bin\\ssh-keygen.exe";
|
||||
const native = "C:\\Windows\\System32\\OpenSSH\\ssh-keygen.exe";
|
||||
const files = new Set([msys, native, "C:\\msys64\\usr\\bin\\msys-2.0.dll"]);
|
||||
const exists = file => files.has(file);
|
||||
assert.equal(selectFidoAskpassWrapper(wrapper, msys, "win32", {}, exists), "C:/Users/First Last/askpass.sh");
|
||||
assert.equal(selectFidoAskpassWrapper(wrapper, native, "win32", {}, exists), wrapper);
|
||||
assert.equal(selectFidoAskpassWrapper(wrapper, "ssh-keygen", "win32", {
|
||||
PATH: "C:\\Windows\\System32\\OpenSSH;C:\\msys64\\usr\\bin",
|
||||
}, exists), wrapper, "the first executable on PATH determines the runtime");
|
||||
});
|
||||
|
||||
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,115 @@
|
||||
"use strict";
|
||||
|
||||
// Native OpenSSH clients must sign through the same caller-bound agent wrapper
|
||||
// as ssh2. A raw singleton socket cannot attribute a request to its window.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { randomUUID, randomBytes, timingSafeEqual } = require("node:crypto");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const { isWindowsPosixSsh } = require("./fidoAskpass.cjs");
|
||||
const { AgentProtocol } = require("ssh2/lib/agent.js");
|
||||
|
||||
// MSYS/Cygwin emulate AF_UNIX with an authenticated loopback connection.
|
||||
// Match their secret + peer-credential handshake; never expose agent protocol
|
||||
// until the caller proves it can read the private socket descriptor file.
|
||||
function authenticateCygwinSocket(socket, secret, onAuthenticated) {
|
||||
let buffered = Buffer.alloc(0);
|
||||
let haveSecret = false;
|
||||
socket.setTimeout(5000, () => socket.destroy());
|
||||
function receive(chunk) {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
if (!haveSecret) {
|
||||
if (buffered.length < 16) return;
|
||||
if (!timingSafeEqual(buffered.subarray(0, 16), secret)) { socket.destroy(); return; }
|
||||
buffered = buffered.subarray(16);
|
||||
haveSecret = true;
|
||||
socket.write(secret);
|
||||
}
|
||||
if (buffered.length < 12) return;
|
||||
const credentials = Buffer.from(buffered.subarray(0, 12));
|
||||
credentials.writeUInt32LE(process.pid, 0);
|
||||
socket.write(credentials);
|
||||
socket.removeListener("data", receive);
|
||||
socket.setTimeout(0);
|
||||
if (buffered.length > 12) socket.unshift(buffered.subarray(12));
|
||||
onAuthenticated(socket);
|
||||
}
|
||||
socket.on("data", receive);
|
||||
}
|
||||
|
||||
async function createNativeFidoAgent(agent, options = {}) {
|
||||
const base = require("./tempDirBridge.cjs").getTempDir();
|
||||
const dir = await fs.promises.mkdtemp(path.join(base, "fn-"));
|
||||
const cygwin = isWindowsPosixSsh(options.sshBinaryPath);
|
||||
const secret = cygwin ? randomBytes(16) : null;
|
||||
const socketPath = process.platform === "win32" && !cygwin
|
||||
? `\\\\.\\pipe\\netcatty-fido-native-${randomUUID()}`
|
||||
: path.join(dir, "agent.sock");
|
||||
const sockets = new Set();
|
||||
let closed = false;
|
||||
function serve(socket) {
|
||||
const protocol = new AgentProtocol(false);
|
||||
protocol.on("error", () => socket.destroy());
|
||||
const respond = operation => {
|
||||
if (!closed && !socket.destroyed && !protocol.destroyed) {
|
||||
try { operation(); } catch { socket.destroy(); }
|
||||
}
|
||||
};
|
||||
protocol.on("identities", request => {
|
||||
agent.getIdentities((error, keys) => respond(() => error
|
||||
? protocol.failureReply(request) : protocol.getIdentitiesReply(request, keys)));
|
||||
});
|
||||
protocol.on("sign", (request, key, data, flags) => {
|
||||
// Enforce the selected identities for direct native signing requests too.
|
||||
agent.getIdentities((error, keys) => {
|
||||
if (closed || socket.destroyed) return;
|
||||
if (error || !Array.isArray(keys)
|
||||
|| !keys.some(candidate => candidate.getPublicSSH().equals(key.getPublicSSH()))) {
|
||||
respond(() => protocol.failureReply(request));
|
||||
return;
|
||||
}
|
||||
agent.sign(key, data, flags, (signError, signature) => respond(() => signError
|
||||
? protocol.failureReply(request) : protocol.signReply(request, signature)));
|
||||
});
|
||||
});
|
||||
socket.pipe(protocol).pipe(socket);
|
||||
}
|
||||
const server = net.createServer(socket => {
|
||||
sockets.add(socket);
|
||||
socket.on("error", () => {});
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
if (cygwin) authenticateCygwinSocket(socket, secret, serve);
|
||||
else serve(socket);
|
||||
});
|
||||
function close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const socket of sockets) socket.destroy();
|
||||
server.close(() => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {}));
|
||||
}
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
const address = cygwin ? { host: "127.0.0.1", port: 0 } : socketPath;
|
||||
server.listen(address, () => { server.removeListener("error", reject); resolve(); });
|
||||
});
|
||||
if (cygwin) {
|
||||
const words = Array.from({ length: 4 }, (_, i) => secret.readUInt32LE(i * 4).toString(16).toUpperCase().padStart(8, "0"));
|
||||
await fs.promises.writeFile(socketPath, `!<socket >${server.address().port} s ${words.join("-")}\0`, { mode: 0o600, flag: "wx" });
|
||||
// The SYSTEM attribute makes MSYS recognize the descriptor as a socket.
|
||||
await promisify(execFile)(path.win32.join(process.env.SystemRoot || "C:\\Windows", "System32", "attrib.exe"),
|
||||
["+S", socketPath], { windowsHide: true, timeout: 5000 });
|
||||
}
|
||||
if (process.platform !== "win32") await fs.promises.chmod(socketPath, 0o600);
|
||||
server.on("error", close);
|
||||
return { socketPath: cygwin ? socketPath.replace(/\\/g, "/") : socketPath, close };
|
||||
} catch (error) {
|
||||
close();
|
||||
await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createNativeFidoAgent, authenticateCygwinSocket };
|
||||
@@ -0,0 +1,179 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { generateKeyPairSync, randomBytes } = require("node:crypto");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const net = require("node:net");
|
||||
const { once } = require("node:events");
|
||||
const { utils } = require("ssh2");
|
||||
const { createAgent } = require("ssh2/lib/agent.js");
|
||||
const { createNativeFidoAgent, authenticateCygwinSocket } = require("./fidoNativeAgent.cjs");
|
||||
const askpass = require("./fidoAskpass.cjs");
|
||||
const prompts = require("./fidoPromptHandler.cjs");
|
||||
const run = promisify(execFile);
|
||||
|
||||
function key() {
|
||||
const pair = generateKeyPairSync("rsa", { modulusLength: 2048,
|
||||
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
||||
publicKeyEncoding: { type: "spki", format: "pem" } });
|
||||
return utils.parseKey(pair.privateKey);
|
||||
}
|
||||
|
||||
function sign(agent, key, data) {
|
||||
return new Promise((resolve, reject) => agent.sign(key, data, { hash: "sha256" },
|
||||
(error, signature) => error ? reject(error) : resolve(signature)));
|
||||
}
|
||||
|
||||
test("native agent sockets attribute concurrent helper subprocess prompts to their signing windows", async () => {
|
||||
const selected = key();
|
||||
const seen = [];
|
||||
const proxies = [];
|
||||
const envs = [];
|
||||
try {
|
||||
for (const id of [2823, 2824]) {
|
||||
const sender = { id, isDestroyed: () => false, send(channel, request) {
|
||||
if (channel !== "netcatty:fido-prompt-request") return;
|
||||
seen.push(id);
|
||||
queueMicrotask(() => prompts.handleResponse({ sender }, {
|
||||
requestId: request.requestId, response: `test-pin-${id}`, cancelled: false,
|
||||
}));
|
||||
} };
|
||||
const env = askpass.buildFidoAskpassEnv({ resolveWebContents: () => sender });
|
||||
envs.push(env);
|
||||
const wrapped = {
|
||||
getIdentities: callback => callback(null, [selected]),
|
||||
sign(publicKey, data, flags, callback) {
|
||||
askpass.runWithFidoSigningLease(env.NETCATTY_FIDO_ASKPASS_LEASE, finish => {
|
||||
const { NETCATTY_FIDO_ASKPASS_LEASE: _lease, ...helperEnv } = env;
|
||||
// Real generated helper subprocess, with the shared agent's
|
||||
// leaseless environment, as OpenSSH's ssh-sk-helper would use it.
|
||||
run(process.execPath, [askpass.ensureFidoAskpass().scriptPath, "Enter PIN for authenticator:"], {
|
||||
env: { ...process.env, ...helperEnv }, timeout: 10000,
|
||||
}).then(result => {
|
||||
assert.equal(result.stdout.trim(), `test-pin-${id}`);
|
||||
finish();
|
||||
callback(null, selected.sign(data, flags.hash));
|
||||
}, error => { finish(); callback(error); });
|
||||
});
|
||||
},
|
||||
};
|
||||
proxies.push(await createNativeFidoAgent(wrapped));
|
||||
}
|
||||
const data = Buffer.from("native SSH signing request");
|
||||
const signatures = await Promise.all(proxies.map(proxy => sign(createAgent(proxy.socketPath), selected, data)));
|
||||
assert.deepEqual(seen, process.platform === "win32" ? [2823, 2823, 2824, 2824] : [2823, 2824]);
|
||||
for (const signature of signatures) assert.equal(selected.verify(data, signature, "sha256"), true);
|
||||
} finally {
|
||||
for (const proxy of proxies) proxy.close();
|
||||
for (const env of envs) askpass.releaseFidoAskpassLease(env.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
askpass.shutdownFidoAskpass();
|
||||
}
|
||||
});
|
||||
|
||||
test("native agent rejects signing identities outside the session selection and closes on release", async () => {
|
||||
const selected = key();
|
||||
const unrelated = key();
|
||||
let signCalls = 0;
|
||||
const proxy = await createNativeFidoAgent({
|
||||
getIdentities: callback => callback(null, [selected]),
|
||||
sign: () => { signCalls += 1; },
|
||||
});
|
||||
try {
|
||||
const client = createAgent(proxy.socketPath);
|
||||
await assert.rejects(sign(client, unrelated, Buffer.from("unauthorized")), /failure/);
|
||||
assert.equal(signCalls, 0);
|
||||
} finally { proxy.close(); }
|
||||
await assert.rejects(new Promise((resolve, reject) => createAgent(proxy.socketPath).getIdentities(
|
||||
error => error ? reject(error) : resolve())), /Failed to connect to agent/);
|
||||
});
|
||||
|
||||
test("unsupported OpenSSH extensions do not corrupt the following identity request", { timeout: 5000 }, async () => {
|
||||
const selected = key();
|
||||
const proxy = await createNativeFidoAgent({ getIdentities: callback => callback(null, [selected]) });
|
||||
const socket = net.createConnection(proxy.socketPath);
|
||||
function frame(type, payload = Buffer.alloc(0)) {
|
||||
const buffer = Buffer.alloc(5 + payload.length);
|
||||
buffer.writeUInt32BE(1 + payload.length);
|
||||
buffer[4] = type;
|
||||
payload.copy(buffer, 5);
|
||||
return buffer;
|
||||
}
|
||||
try {
|
||||
await once(socket, "connect");
|
||||
const responses = new Promise((resolve, reject) => {
|
||||
let buffered = Buffer.alloc(0);
|
||||
const types = [];
|
||||
socket.on("error", reject);
|
||||
socket.on("data", chunk => {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
while (buffered.length >= 5 && buffered.length >= 4 + buffered.readUInt32BE()) {
|
||||
types.push(buffered[4]);
|
||||
buffered = buffered.subarray(4 + buffered.readUInt32BE());
|
||||
}
|
||||
if (types.length >= 3) resolve(types);
|
||||
});
|
||||
});
|
||||
// Coalesce identities, an unknown payload, and identities in one write.
|
||||
// This also proves parsing advances correctly after a preceding frame.
|
||||
socket.write(Buffer.concat([frame(11), frame(27, Buffer.from("unsupported-extension-payload")), frame(11)]));
|
||||
assert.deepEqual(await responses, [12, 5, 12]);
|
||||
} finally { socket.destroy(); proxy.close(); }
|
||||
});
|
||||
|
||||
|
||||
test("Cygwin socket authenticates fragmented handshakes and rejects a wrong secret", { timeout: 5000 }, async () => {
|
||||
const secret = randomBytes(16);
|
||||
let admitted = 0;
|
||||
const sockets = new Set();
|
||||
const server = net.createServer(socket => {
|
||||
sockets.add(socket);
|
||||
socket.on("error", () => {});
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
authenticateCygwinSocket(socket, secret, authenticated => {
|
||||
admitted += 1;
|
||||
authenticated.on("data", data => authenticated.write(data));
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const connect = () => net.createConnection(server.address().port, "127.0.0.1");
|
||||
const bad = connect();
|
||||
const good = connect();
|
||||
const goodConnected = once(good, "connect");
|
||||
try {
|
||||
await once(bad, "connect");
|
||||
const closed = once(bad, "close");
|
||||
bad.write(Buffer.alloc(16));
|
||||
await closed;
|
||||
assert.equal(admitted, 0);
|
||||
await goodConnected;
|
||||
const echo = once(good, "data");
|
||||
good.write(secret.subarray(0, 7));
|
||||
good.write(secret.subarray(7));
|
||||
assert.deepEqual((await echo)[0], secret);
|
||||
const credentials = Buffer.alloc(12);
|
||||
credentials.writeUInt32LE(1001, 4);
|
||||
credentials.writeUInt32LE(1002, 8);
|
||||
const reply = new Promise(resolve => {
|
||||
let bytes = Buffer.alloc(0);
|
||||
good.on("data", chunk => {
|
||||
bytes = Buffer.concat([bytes, chunk]);
|
||||
if (bytes.length >= 17) resolve(bytes);
|
||||
});
|
||||
});
|
||||
good.write(credentials.subarray(0, 5));
|
||||
// Preserve agent bytes coalesced with the end of the credential handshake.
|
||||
good.write(Buffer.concat([credentials.subarray(5), Buffer.from("agent")]));
|
||||
const bytes = await reply;
|
||||
assert.equal(bytes.readUInt32LE(0), process.pid);
|
||||
assert.deepEqual(bytes.subarray(4, 12), credentials.subarray(4));
|
||||
assert.equal(bytes.subarray(12).toString(), "agent");
|
||||
assert.equal(admitted, 1);
|
||||
} finally {
|
||||
bad.destroy(); good.destroy();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
"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", notificationsOnly = false) {
|
||||
if (typeof leaseId !== "string" || !leaseId) return 0;
|
||||
let cancelled = 0;
|
||||
for (const [requestId, pending] of [...fidoPromptRequests.entries()]) {
|
||||
if (pending.leaseId !== leaseId) continue;
|
||||
if (notificationsOnly && !pending.notificationOnly) 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,
|
||||
notificationOnly: options.notificationOnly === true,
|
||||
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,152 @@
|
||||
"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,
|
||||
sshBinaryPath: sshKeygen,
|
||||
env,
|
||||
});
|
||||
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;
|
||||
|
||||
@@ -532,17 +532,18 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
await fs.promises.writeFile(path.join(secondDir, "target.bin"), secondOriginal);
|
||||
await fs.promises.symlink(path.basename(firstDir), parentLink);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 1) {
|
||||
await fs.promises.unlink(parentLink);
|
||||
await fs.promises.symlink(path.basename(secondDir), parentLink);
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
// Preparation renames on the same filesystem and copies across devices.
|
||||
for (const operation of ["rename", "copyFile"]) {
|
||||
const original = fs.promises[operation];
|
||||
fs.promises[operation] = async (...args) => {
|
||||
await original(...args);
|
||||
if (String(args[1]).endsWith(".ready")) {
|
||||
await fs.promises.unlink(parentLink);
|
||||
await fs.promises.symlink(path.basename(secondDir), parentLink);
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises[operation] = original; });
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
@@ -618,6 +619,9 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
||||
// Unified transfers pre-create their stage as 0600. writeFile's mode
|
||||
// only applies to creation; make a real mode change necessary here.
|
||||
await fs.promises.chmod(localPath, 0o644);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
@@ -661,6 +665,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
||||
await fs.promises.chmod(localPath, 0o644);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
@@ -710,6 +715,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
||||
await fs.promises.chmod(localPath, 0o644);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
@@ -773,11 +779,9 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 2) controller.abort();
|
||||
if (String(args[1]).endsWith(".backup")) controller.abort();
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
|
||||
@@ -907,15 +911,13 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
fs.writeFileSync(localPath, Buffer.from("existing-local-content"));
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
let readyPath = null;
|
||||
let backupPath = null;
|
||||
fs.promises.rename = async (...args) => {
|
||||
renameCalls += 1;
|
||||
await originalRename(...args);
|
||||
if (renameCalls === 1) readyPath = args[1];
|
||||
if (renameCalls === 2) {
|
||||
if (String(args[1]).endsWith(".backup")) {
|
||||
backupPath = args[1];
|
||||
readyPath = backupPath.replace(/\.backup$/, ".ready");
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,550 @@ async function getNativeOpenSshForwardingAgentSocket(identityAgent, injected = {
|
||||
return socketPath;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
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";
|
||||
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-"));
|
||||
try {
|
||||
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 };
|
||||
} catch (error) {
|
||||
await fsMod.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
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 ?? ""),
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
/** @type {string[]} */
|
||||
let newlyLoadedIdentityPaths = [];
|
||||
/** @type {Array<{ key: string, identityPath: string }>} */
|
||||
let sharedAgentIdentities = [];
|
||||
let fidoResourcesReleased = false;
|
||||
let nativeFidoAgent = null;
|
||||
const releaseAcquiredFidoResources = () => {
|
||||
if (fidoResourcesReleased) return;
|
||||
fidoResourcesReleased = true;
|
||||
nativeFidoAgent?.close();
|
||||
// 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);
|
||||
void systemSshAgent.withAgentIdentityLock(socketPath, async () => {
|
||||
for (const entry of sharedAgentIdentities) {
|
||||
const { shouldRemove, identityPath, cleanupDir, plain } = releaseSharedAgentIdentity(entry.key, socketPath);
|
||||
if (!shouldRemove) continue;
|
||||
if (identityPath) {
|
||||
// Keep all selectors until ssh-add finishes, and prevent a new
|
||||
// preparation from borrowing an identity that is being removed.
|
||||
await new Promise(resolve => {
|
||||
try {
|
||||
childProcess.execFile(sshAdd, plain ? ["-d", "-k", identityPath] : ["-d", identityPath], {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, SSH_AUTH_SOCK: socketPath, SSH_ASKPASS_REQUIRE: "never" },
|
||||
}, () => resolve());
|
||||
} catch { resolve(); }
|
||||
});
|
||||
}
|
||||
const removable = systemSshAgent.finishSharedCleanupDir(cleanupDir);
|
||||
if (removable) await fs.promises.rm(removable, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
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 {
|
||||
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),
|
||||
});
|
||||
throw 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),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const prepEnv = fidoSshAddPath
|
||||
? { ...process.env, NETCATTY_SSH_ADD_PATH: fidoSshAddPath }
|
||||
: process.env;
|
||||
return await systemSshAgent.withAgentIdentityLock(socketPath, async () => {
|
||||
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, socketPath, entry.plain === true);
|
||||
// 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 (options.nativeFidoAgent === true && isFidoFlow) {
|
||||
nativeFidoAgent = await require("./fidoNativeAgent.cjs").createNativeFidoAgent(agent, { sshBinaryPath: options.nativeSshPath });
|
||||
agent._netcattyAgentSocket = nativeFidoAgent.socketPath;
|
||||
}
|
||||
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 +2600,15 @@ module.exports = {
|
||||
SSH_KEY_PATTERN,
|
||||
orderSshIdentityNames,
|
||||
looksLikePrivateKey,
|
||||
looksLikeSkOpenSshMaterial,
|
||||
identityFilesLookLikeSk,
|
||||
isFidoSkAuthOptions,
|
||||
buildFidoAwareAgentPrepOptions,
|
||||
enhanceAuthOptionsForFido,
|
||||
shouldUseSoftwareCertificateAgent,
|
||||
resolveWebContentsFromSender,
|
||||
resolvePreparedAgentSocket,
|
||||
materializeSkPrivateKeyFile,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
"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("failed companion writes remove the partially staged SK handle", async () => {
|
||||
const root = fs.mkdtempSync(path.join(require("./tempDirBridge.cjs").getTempDir(), "fw-"));
|
||||
try {
|
||||
for (const failAt of [2, 3]) {
|
||||
let writes = 0;
|
||||
const injectedFs = { promises: { ...fs.promises, writeFile: async (...args) => {
|
||||
writes += 1;
|
||||
if (writes === failAt) throw Object.assign(new Error("fixture write denied"), { code: "EACCES" });
|
||||
return fs.promises.writeFile(...args);
|
||||
} } };
|
||||
await assert.rejects(materializeSkPrivateKeyFile(makeSkPrivatePem(SK_SSH_ED25519), {
|
||||
fs: injectedFs, publicKey: skPub, certificate: "fixture certificate",
|
||||
tempDirBridge: { getTempDir: () => root },
|
||||
}), { code: "EACCES" });
|
||||
assert.deepEqual(fs.readdirSync(root), [], "partial credential directories must not remain");
|
||||
}
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("failed staging rejects the selected-key login and releases the acquired agent", async () => {
|
||||
const manager = require("./fidoAgentManager.cjs");
|
||||
const askpass = require("./fidoAskpass.cjs");
|
||||
const system = require("./systemSshAgent.cjs");
|
||||
const saved = { acquire: manager.acquireFidoAgent, release: manager.releaseFidoAgent,
|
||||
lease: askpass.releaseFidoAskpassLease, prepare: system.prepareSystemSshAgent, write: fs.promises.writeFile };
|
||||
let prepared = false;
|
||||
let releases = 0;
|
||||
let leases = 0;
|
||||
manager.acquireFidoAgent = async () => ({ socketPath: "/tmp/staging-failure.sock", generation: 7,
|
||||
owned: true, askpassEnv: { NETCATTY_FIDO_ASKPASS_LEASE: "staging-failure" } });
|
||||
manager.releaseFidoAgent = () => { releases += 1; };
|
||||
askpass.releaseFidoAskpassLease = () => { leases += 1; };
|
||||
system.prepareSystemSshAgent = async () => { prepared = true; return {}; };
|
||||
fs.promises.writeFile = async (...args) => {
|
||||
if (path.basename(args[0]) === "id_sk") throw Object.assign(new Error("fixture write denied"), { code: "EACCES" });
|
||||
return saved.write(...args);
|
||||
};
|
||||
try {
|
||||
await assert.rejects(require("./sshAuthHelper.cjs").prepareSystemSshAgentForAuth({
|
||||
privateKey: makeSkPrivatePem(SK_SSH_ED25519),
|
||||
}), { code: "EACCES" });
|
||||
assert.equal(prepared, false, "staging failure must not offer other sessions' identities");
|
||||
assert.equal(releases, 1);
|
||||
assert.equal(leases, 1);
|
||||
} finally {
|
||||
manager.acquireFidoAgent = saved.acquire;
|
||||
manager.releaseFidoAgent = saved.release;
|
||||
askpass.releaseFidoAskpassLease = saved.lease;
|
||||
system.prepareSystemSshAgent = saved.prepare;
|
||||
fs.promises.writeFile = saved.write;
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [], "first release must keep the identity for the surviving session");
|
||||
agentB._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
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();
|
||||
await new Promise(setImmediate);
|
||||
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();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [], "first release must keep the shared identity loaded");
|
||||
agentB._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
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, socketPath) => (
|
||||
originalRetain(key, idPath, cleanupDir || stagingDir, socketPath)
|
||||
);
|
||||
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();
|
||||
await new Promise(setImmediate);
|
||||
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,19 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
}
|
||||
|
||||
if (!agent && auth.useSshAgent && typeof prepareSystemSshAgentForAuth === "function") {
|
||||
connectOpts.agent = await prepareSystemSshAgentForAuth(auth, `[${label} Stats]`);
|
||||
const prepared = await prepareSystemSshAgentForAuth({
|
||||
...auth,
|
||||
resolveWebContents: () => auth.webContents && !auth.webContents.isDestroyed?.()
|
||||
? auth.webContents : null,
|
||||
}, `[${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 +426,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 +490,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 +499,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 +524,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,89 @@ 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("FIDO stats preparation resolves only its requesting live window", async () => {
|
||||
let prepared;
|
||||
let destroyed = false;
|
||||
const sender = { id: 2823, isDestroyed: () => destroyed };
|
||||
const { api, sessions } = makeApi({ prepareSystemSshAgentForAuth: async auth => {
|
||||
prepared = auth;
|
||||
return "/tmp/fido-stats.sock";
|
||||
} });
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", useSshAgent: true } };
|
||||
sessions.set("sid", session);
|
||||
api.ensureMoshStatsConnection(session, "sid", sender);
|
||||
await tick();
|
||||
assert.equal(prepared.resolveWebContents(), sender);
|
||||
destroyed = true;
|
||||
assert.equal(prepared.resolveWebContents(), null);
|
||||
FakeSSHClient.instances[0].emit("close");
|
||||
});
|
||||
|
||||
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 +769,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,114 @@ 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, plain?: boolean }>}
|
||||
*/
|
||||
const sharedAgentIdentityRefs = new Map();
|
||||
const agentIdentityLocks = new Map();
|
||||
const sharedCleanupDirs = new Map();
|
||||
|
||||
function withAgentIdentityLock(socketPath, operation) {
|
||||
const previous = agentIdentityLocks.get(socketPath) || Promise.resolve();
|
||||
const result = previous.catch(() => {}).then(operation);
|
||||
const tail = result.catch(() => {});
|
||||
agentIdentityLocks.set(socketPath, tail);
|
||||
void tail.then(() => {
|
||||
if (agentIdentityLocks.get(socketPath) === tail) agentIdentityLocks.delete(socketPath);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function identityRefKey(key, socketPath) {
|
||||
return socketPath ? `${socketPath}\0${key}` : key;
|
||||
}
|
||||
|
||||
function finishSharedCleanupDir(cleanupDir) {
|
||||
if (!cleanupDir) return null;
|
||||
const remaining = (sharedCleanupDirs.get(cleanupDir) || 1) - 1;
|
||||
if (remaining > 0) { sharedCleanupDirs.set(cleanupDir, remaining); return null; }
|
||||
sharedCleanupDirs.delete(cleanupDir);
|
||||
return cleanupDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
* @param {string} [socketPath] Agent owning this identity
|
||||
* @param {boolean} [plain] Remove the bare key with ssh-add -k, preserving certificates
|
||||
* @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, socketPath = "", plain = false) {
|
||||
if (typeof key !== "string" || !key || typeof identityPath !== "string" || !identityPath) {
|
||||
return null;
|
||||
}
|
||||
const refKey = identityRefKey(key, socketPath);
|
||||
const existing = sharedAgentIdentityRefs.get(refKey);
|
||||
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,
|
||||
...(plain ? { plain: true } : {}),
|
||||
};
|
||||
sharedAgentIdentityRefs.set(refKey, entry);
|
||||
if (normalizedCleanup) sharedCleanupDirs.set(normalizedCleanup, (sharedCleanupDirs.get(normalizedCleanup) || 0) + 1);
|
||||
return { entry, acceptedCleanup: Boolean(normalizedCleanup) };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {string} [socketPath]
|
||||
* @returns {{ shouldRemove: boolean, identityPath: string|null, cleanupDir: string|null }}
|
||||
*/
|
||||
function releaseSharedAgentIdentity(key, socketPath = "") {
|
||||
if (typeof key !== "string" || !key) {
|
||||
return { shouldRemove: false, identityPath: null, cleanupDir: null };
|
||||
}
|
||||
const refKey = identityRefKey(key, socketPath);
|
||||
const existing = sharedAgentIdentityRefs.get(refKey);
|
||||
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(refKey);
|
||||
return {
|
||||
shouldRemove: true,
|
||||
...(existing.plain ? { plain: true } : {}),
|
||||
identityPath: existing.identityPath,
|
||||
cleanupDir: existing.cleanupDir,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasSharedAgentIdentity(key, socketPath = "") {
|
||||
return typeof key === "string" && key.length > 0 && sharedAgentIdentityRefs.has(identityRefKey(key, socketPath));
|
||||
}
|
||||
|
||||
function resetSharedAgentIdentityRefsForTests() {
|
||||
sharedAgentIdentityRefs.clear();
|
||||
sharedCleanupDirs.clear();
|
||||
}
|
||||
|
||||
function resolveIdentityPath(rawPath, context = {}) {
|
||||
if (typeof rawPath !== "string") return "";
|
||||
const env = context.env ?? process.env;
|
||||
@@ -59,6 +167,7 @@ function resolveIdentityPath(rawPath, context = {}) {
|
||||
|
||||
async function loadPreferredPublicKeyBlobs(identityFilePaths, publicKeys, options, deps) {
|
||||
const preferred = new Set();
|
||||
const identityBlobs = new Map();
|
||||
const resolvedIdentityPaths = [];
|
||||
const unavailablePublicKeyPaths = [];
|
||||
let providedPreferredCount = 0;
|
||||
@@ -77,9 +186,18 @@ async function loadPreferredPublicKeyBlobs(identityFilePaths, publicKeys, option
|
||||
try {
|
||||
const contents = await deps.readFile(publicKeyPath, "utf8");
|
||||
const blob = publicKeyBlob(contents);
|
||||
if (blob) preferred.add(blob);
|
||||
if (blob) { preferred.add(blob); identityBlobs.set(identityPath, blob); }
|
||||
else unavailablePublicKeyPaths.push(publicKeyPath);
|
||||
} catch (error) {
|
||||
// An unencrypted SK handle contains its public identity. Keep strict
|
||||
// filtering even when the optional companion .pub file is absent.
|
||||
try {
|
||||
const key = utils.parseKey(await deps.readFile(identityPath, "utf8"));
|
||||
if (!(key instanceof Error) && key.type?.startsWith("sk-")) {
|
||||
const blob = publicKeyBlob(key);
|
||||
if (blob) { preferred.add(blob); identityBlobs.set(identityPath, blob); continue; }
|
||||
}
|
||||
} catch { /* retain the missing-selector error below */ }
|
||||
unavailablePublicKeyPaths.push(publicKeyPath);
|
||||
deps.log?.("Configured SSH public key is unavailable", {
|
||||
publicKeyPath,
|
||||
@@ -87,7 +205,7 @@ async function loadPreferredPublicKeyBlobs(identityFilePaths, publicKeys, option
|
||||
});
|
||||
}
|
||||
}
|
||||
return { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths };
|
||||
return { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths, identityBlobs };
|
||||
}
|
||||
|
||||
function getIdentities(agent) {
|
||||
@@ -143,18 +261,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,9 +376,10 @@ 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(
|
||||
const { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths, identityBlobs } = await loadPreferredPublicKeyBlobs(
|
||||
options.identityFilePaths,
|
||||
options.agentPublicKeys,
|
||||
{ hostname: options.hostname, port: options.port, username: options.username, env: deps.env },
|
||||
@@ -192,7 +405,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 +421,144 @@ 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 = identityBlobs.get(identityPath) || null;
|
||||
/** @type {string|null} */
|
||||
let certBlob = null;
|
||||
let bareAlreadyPresent = Boolean(blob && loadedBlobs.has(blob));
|
||||
let companionCertificateMissing = false;
|
||||
let certificateAlreadyPresent = 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 (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);
|
||||
certificateAlreadyPresent = Boolean(certBlob && loadedBlobs.has(certBlob));
|
||||
if (certBlob && !certificateAlreadyPresent) {
|
||||
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, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key, identityPath, plain: true });
|
||||
}
|
||||
if (certBlob && hasSharedAgentIdentity(certBlob, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key: certBlob, identityPath: certPath });
|
||||
}
|
||||
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) {
|
||||
newlyLoadedIdentityPaths.push(identityPath);
|
||||
sharedAgentIdentities.push({ key, identityPath, plain: true });
|
||||
} else if (hasSharedAgentIdentity(key, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key, identityPath, plain: true });
|
||||
}
|
||||
if (certBlob && certPath) {
|
||||
if (!certificateAlreadyPresent) newlyLoadedIdentityPaths.push(certPath);
|
||||
if (!certificateAlreadyPresent || hasSharedAgentIdentity(certBlob, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key: certBlob, identityPath: certPath });
|
||||
}
|
||||
}
|
||||
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 plain = sharedAgentIdentities.some(entry => entry.identityPath === identityPath && entry.plain);
|
||||
const args = plain ? ["-d", "-k", identityPath] : ["-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,13 +568,28 @@ 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 = {
|
||||
IdentityAwareAgent,
|
||||
withAgentIdentityLock,
|
||||
finishSharedCleanupDir,
|
||||
prepareSystemSshAgent,
|
||||
publicKeyBlob,
|
||||
resolveIdentityPath,
|
||||
resolveSshAddBinary,
|
||||
shouldLoadFromMacKeychain,
|
||||
shouldLoadIdentityFileIntoAgent,
|
||||
retainSharedAgentIdentity,
|
||||
releaseSharedAgentIdentity,
|
||||
hasSharedAgentIdentity,
|
||||
resetSharedAgentIdentityRefsForTests,
|
||||
};
|
||||
@@ -0,0 +1,503 @@
|
||||
"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";
|
||||
|
||||
test("identity preparation waits for deletion on the same socket, without blocking other agents", async () => {
|
||||
const { withAgentIdentityLock } = require("./systemSshAgent.cjs");
|
||||
let finishDelete;
|
||||
let inspected = false;
|
||||
const deletion = withAgentIdentityLock("agent-a", () => new Promise(resolve => { finishDelete = resolve; }));
|
||||
await new Promise(setImmediate);
|
||||
const preparation = withAgentIdentityLock("agent-a", async () => { inspected = true; });
|
||||
await withAgentIdentityLock("agent-b", async () => {});
|
||||
assert.equal(inspected, false, "a new login cannot borrow an identity pending removal");
|
||||
finishDelete();
|
||||
await Promise.all([deletion, preparation]);
|
||||
assert.equal(inspected, true);
|
||||
});
|
||||
|
||||
test("identity holds are scoped to their agent and retain staging until bare key and certificate removal finish", () => {
|
||||
const { retainSharedAgentIdentity, releaseSharedAgentIdentity, finishSharedCleanupDir,
|
||||
resetSharedAgentIdentityRefsForTests } = require("./systemSshAgent.cjs");
|
||||
resetSharedAgentIdentityRefsForTests();
|
||||
try {
|
||||
retainSharedAgentIdentity("bare", "/tmp/key", "/tmp/stage", "agent-a", true);
|
||||
retainSharedAgentIdentity("cert", "/tmp/cert.pub", "/tmp/stage", "agent-a");
|
||||
retainSharedAgentIdentity("bare", "/tmp/other-key", null, "agent-b", true);
|
||||
const bare = releaseSharedAgentIdentity("bare", "agent-a");
|
||||
assert.equal(bare.shouldRemove, true);
|
||||
assert.equal(bare.plain, true, "bare removal must skip companion certificates");
|
||||
assert.equal(finishSharedCleanupDir(bare.cleanupDir), null);
|
||||
const cert = releaseSharedAgentIdentity("cert", "agent-a");
|
||||
assert.equal(finishSharedCleanupDir(cert.cleanupDir), "/tmp/stage");
|
||||
assert.equal(releaseSharedAgentIdentity("bare", "agent-b").identityPath, "/tmp/other-key");
|
||||
} finally { resetSharedAgentIdentityRefsForTests(); }
|
||||
});
|
||||
|
||||
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, plain: true },
|
||||
]);
|
||||
});
|
||||
|
||||
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", null, "/tmp/fake-agent.sock");
|
||||
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.deepEqual(prepared._netcattyNewlyLoadedIdentityPaths, [certPath]);
|
||||
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
|
||||
{ key: blob, identityPath, plain: true },
|
||||
{ key: certBlob, identityPath: certPath },
|
||||
]);
|
||||
} 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", null, "/tmp/fake-agent.sock");
|
||||
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, plain: true },
|
||||
]);
|
||||
} 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();
|
||||
}
|
||||
});
|
||||
@@ -165,7 +165,7 @@ test("Mosh prepares the configured system agent before building native ssh optio
|
||||
assert.deepEqual(auth.sshArgs, [
|
||||
"-i", path.join(os.homedir(), ".ssh", "id_work.pub"),
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "IdentityAgent=/tmp/custom-agent.sock",
|
||||
"-o", 'IdentityAgent="/tmp/custom-agent.sock"',
|
||||
"-o", "StrictHostKeyChecking=ask",
|
||||
]);
|
||||
assert.equal(env.SSH_AUTH_SOCK, "/tmp/custom-agent.sock");
|
||||
@@ -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, nativeFidoAgent: true, nativeSshPath: process.platform === "win32" ? findExecutable("ssh") : undefined }, 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, sshExe) {
|
||||
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, nativeFidoAgent: true, nativeSshPath: sshExe }, "[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) {
|
||||
@@ -367,7 +412,9 @@ function createMoshSessionApi(ctx) {
|
||||
if (options.useSshAgent === false) {
|
||||
sshArgs.push("-o", "IdentityAgent=none");
|
||||
} else if (options.useSshAgent && options._resolvedSshAgentSocket) {
|
||||
sshArgs.push("-o", `IdentityAgent=${options._resolvedSshAgentSocket}`);
|
||||
// -o is parsed again as ssh_config syntax, even with argv spawning.
|
||||
const quotedSocket = `"${String(options._resolvedSshAgentSocket).replace(/(["\\])/g, "\\$1")}"`;
|
||||
sshArgs.push("-o", `IdentityAgent=${quotedSocket}`);
|
||||
if (options.identitiesOnly && !sshArgs.includes("IdentitiesOnly=yes")) {
|
||||
sshArgs.push("-o", "IdentitiesOnly=yes");
|
||||
}
|
||||
@@ -480,38 +527,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 +570,8 @@ function createMoshSessionApi(ctx) {
|
||||
useConptyDll: process.platform === "win32",
|
||||
});
|
||||
} catch (err) {
|
||||
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
|
||||
if (moshAuth) cleanupMoshAuthTempFiles(moshAuth.tempFiles);
|
||||
releaseFidoAgent();
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -564,6 +614,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 +692,7 @@ function createMoshSessionApi(ctx) {
|
||||
});
|
||||
|
||||
sshPty.onExit(({ exitCode, signal }) => {
|
||||
releaseFidoAgent();
|
||||
if (sessions.get(sessionId) !== session || session.closed) {
|
||||
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
|
||||
return;
|
||||
@@ -961,7 +1013,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, sshExe);
|
||||
return startMoshSessionViaHandshake(event, preparedOptions, { bareClient, sshExe });
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ test("failed copy followed by exclusive-open collision leaves that entry intact"
|
||||
|
||||
for (const metadataError of ["ENOTSUP", "EPERM", "EACCES"]) {
|
||||
test(`rollback retains backup when its original timestamps cannot be restored: ${metadataError}`, async (t) => {
|
||||
const setup = await mountedDestination(t, { existingMode: 0o644, cancelBeforeFinal: true, metadataError });
|
||||
const setup = await mountedDestination(t, { existingMode: 0o644, virtualMode: 0o644, cancelBeforeFinal: true, metadataError });
|
||||
await assert.rejects(setup.run, /Recovery files preserved/);
|
||||
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
|
||||
assert.ok(backup);
|
||||
|
||||
@@ -312,12 +312,17 @@ function createVaultBackupService({ app, safeStorage, shell }) {
|
||||
|
||||
const getBackupDir = () => path.join(app.getPath("userData"), BACKUP_DIR_NAME);
|
||||
|
||||
// Serialize createBackup so two concurrent calls (version-change backup
|
||||
// Serialize backup mutations so two concurrent calls (version-change backup
|
||||
// running at startup + an explicit protective-before-restore triggered
|
||||
// by the user's click, etc.) observe each other's writes. Without this,
|
||||
// both observers would see an empty directory, compute the same
|
||||
// fingerprint, skip the dedupe, and write two identical files.
|
||||
let createBackupLock = Promise.resolve();
|
||||
let mutationLock = Promise.resolve();
|
||||
const mutate = (operation) => {
|
||||
const next = mutationLock.then(operation);
|
||||
mutationLock = next.catch(() => undefined);
|
||||
return next;
|
||||
};
|
||||
// Monotonically increasing `createdAt` per service instance. `Date.now()`
|
||||
// has 1ms resolution and back-to-back async calls (version-change backup
|
||||
// followed immediately by a protective backup) can land in the same
|
||||
@@ -332,12 +337,7 @@ function createVaultBackupService({ app, safeStorage, shell }) {
|
||||
},
|
||||
|
||||
async createBackup(options = {}) {
|
||||
const next = createBackupLock.then(() => doCreateBackup(options));
|
||||
// Swallow the rejection on the lock chain so one caller's error
|
||||
// does not poison subsequent calls; each individual await sees its
|
||||
// own rejection via the `next` return.
|
||||
createBackupLock = next.catch(() => undefined);
|
||||
return next;
|
||||
return mutate(() => doCreateBackup(options));
|
||||
},
|
||||
|
||||
async listBackups() {
|
||||
@@ -364,7 +364,44 @@ function createVaultBackupService({ app, safeStorage, shell }) {
|
||||
},
|
||||
|
||||
async trimBackups(options = {}) {
|
||||
return pruneBackupRecords(getBackupDir(), options.maxCount);
|
||||
return mutate(() => pruneBackupRecords(getBackupDir(), options.maxCount));
|
||||
},
|
||||
|
||||
async clearBackups() {
|
||||
return mutate(async () => {
|
||||
const dirPath = getBackupDir();
|
||||
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
let deletedCount = 0;
|
||||
let failedCount = 0;
|
||||
for (const entry of entries) {
|
||||
// Include unreadable records and interrupted writes without reading
|
||||
// or decrypting them. Preserve unrelated files and directories.
|
||||
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
||||
if (!entry.name.startsWith(BACKUP_FILE_PREFIX)) continue;
|
||||
if (!entry.name.endsWith(BACKUP_FILE_EXT)
|
||||
&& !entry.name.includes(`${BACKUP_FILE_EXT}.tmp-`)) continue;
|
||||
try {
|
||||
await fs.promises.unlink(path.join(dirPath, entry.name));
|
||||
deletedCount += 1;
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") failedCount += 1;
|
||||
}
|
||||
}
|
||||
// Persist directory changes before reporting success, including on a
|
||||
// retry after a previous sync failure left no backup files to unlink.
|
||||
// Windows does not support opening directory handles this way.
|
||||
if (process.platform !== 'win32') {
|
||||
const dirHandle = await fs.promises.open(dirPath, 'r');
|
||||
try {
|
||||
await dirHandle.sync();
|
||||
} finally {
|
||||
await dirHandle.close();
|
||||
}
|
||||
}
|
||||
if (failedCount) throw new Error(`Could not delete ${failedCount} vault backup file(s).`);
|
||||
return { deletedCount };
|
||||
});
|
||||
},
|
||||
|
||||
async openBackupDir() {
|
||||
@@ -577,6 +614,14 @@ function registerHandlers(ipcMain, electronModule) {
|
||||
}
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:clear", async () => {
|
||||
try {
|
||||
return await service.clearBackups();
|
||||
} finally {
|
||||
// Refresh every window after partial deletion as well as full success.
|
||||
broadcastBackupsChanged();
|
||||
}
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:openDir", async () => {
|
||||
return service.openBackupDir();
|
||||
});
|
||||
|
||||
@@ -716,3 +716,100 @@ test("readBackupRecord rejects oversized files before buffering them", async ()
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clearBackups removes encrypted, corrupt and interrupted backup files only', async () => {
|
||||
const root = createTempRoot();
|
||||
try {
|
||||
const service = createService(root);
|
||||
await service.createBackup({ payload: samplePayload() });
|
||||
const dir = path.join(root, BACKUP_DIR_NAME);
|
||||
fs.writeFileSync(path.join(dir, 'vault-backup-bad.json'), '{broken');
|
||||
fs.writeFileSync(path.join(dir, 'vault-backup-interrupted.json.tmp-abc'), 'secret');
|
||||
fs.writeFileSync(path.join(dir, 'keep.txt'), 'unrelated');
|
||||
fs.mkdirSync(path.join(dir, 'vault-backup-directory.json'));
|
||||
const unavailable = createService(root, { encrypted: false });
|
||||
assert.deepEqual(await unavailable.clearBackups(), { deletedCount: 3 });
|
||||
assert.deepEqual(await service.listBackups(), []);
|
||||
assert.deepEqual(fs.readdirSync(dir).sort(), ['keep.txt', 'vault-backup-directory.json']);
|
||||
assert.deepEqual(await service.clearBackups(), { deletedCount: 0 });
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clearBackups waits for pending creates and allows future backups', async () => {
|
||||
const root = createTempRoot();
|
||||
try {
|
||||
const service = createService(root);
|
||||
const create = service.createBackup({ payload: samplePayload() });
|
||||
const clear = service.clearBackups();
|
||||
await create;
|
||||
assert.deepEqual(await clear, { deletedCount: 1 });
|
||||
assert.deepEqual(await service.listBackups(), []);
|
||||
const next = await service.createBackup({ payload: samplePayload() });
|
||||
assert.equal(next.created, true);
|
||||
assert.equal((await service.listBackups()).length, 1);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clearBackups reports partial deletion failures and permits retry', async (t) => {
|
||||
const root = createTempRoot();
|
||||
const originalUnlink = fs.promises.unlink;
|
||||
try {
|
||||
const service = createService(root);
|
||||
await service.createBackup({ payload: samplePayload() });
|
||||
const dir = path.join(root, BACKUP_DIR_NAME);
|
||||
const blocked = path.join(dir, 'vault-backup-blocked.json');
|
||||
fs.writeFileSync(blocked, '{broken');
|
||||
const mock = t.mock.method(fs.promises, 'unlink', async (target) => {
|
||||
if (target === blocked) throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
return originalUnlink(target);
|
||||
});
|
||||
await assert.rejects(service.clearBackups(), /Could not delete 1 vault backup file/);
|
||||
assert.deepEqual(fs.readdirSync(dir), ['vault-backup-blocked.json']);
|
||||
mock.mock.restore();
|
||||
assert.deepEqual(await service.clearBackups(), { deletedCount: 1 });
|
||||
assert.deepEqual(fs.readdirSync(dir), []);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clearBackups persists deletion and retries a failed directory sync even when empty', {
|
||||
skip: process.platform === 'win32',
|
||||
}, async (t) => {
|
||||
const root = createTempRoot();
|
||||
try {
|
||||
const service = createService(root);
|
||||
await service.createBackup({ payload: samplePayload() });
|
||||
const dir = path.join(root, BACKUP_DIR_NAME);
|
||||
const originalOpen = fs.promises.open;
|
||||
let syncCalls = 0;
|
||||
let closeCalls = 0;
|
||||
t.mock.method(fs.promises, 'open', async (target, ...args) => {
|
||||
const handle = await originalOpen(target, ...args);
|
||||
if (target !== dir) return handle;
|
||||
return {
|
||||
async sync() {
|
||||
syncCalls += 1;
|
||||
assert.deepEqual(fs.readdirSync(dir), []);
|
||||
if (syncCalls === 1) throw new Error('fixture directory sync failed');
|
||||
await handle.sync();
|
||||
},
|
||||
async close() {
|
||||
closeCalls += 1;
|
||||
await handle.close();
|
||||
},
|
||||
};
|
||||
});
|
||||
await assert.rejects(service.clearBackups(), /fixture directory sync failed/);
|
||||
assert.equal(closeCalls, 1);
|
||||
assert.deepEqual(await service.clearBackups(), { deletedCount: 0 });
|
||||
assert.equal(syncCalls, 2);
|
||||
assert.equal(closeCalls, 2);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
@@ -1354,6 +1374,8 @@ function createPreloadApi(ctx) {
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:read", payload),
|
||||
trimVaultBackups: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:trim", payload),
|
||||
clearVaultBackups: () =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:clear"),
|
||||
openVaultBackupDir: () =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:openDir"),
|
||||
// Subscribe to cross-window "backups changed" events emitted by the
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -296,8 +296,6 @@ export const STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB = 'netcatty_local_
|
||||
export const STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH = 'netcatty_workspace_focus_sidebar_width';
|
||||
export const STORAGE_KEY_TERMINAL_HOST_TREE_WIDTH = 'netcatty_terminal_host_tree_width_v1';
|
||||
export const STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED = 'netcatty_terminal_host_tree_collapsed_v1';
|
||||
/** Terminal scripts side panel library: 'list' vs 'stacked' view mode. */
|
||||
export const STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW = 'netcatty:scripts:sidePanelView';
|
||||
export const STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN = 'netcatty_terminal_compose_bar_open_v1';
|
||||
/** Opt-in "broadcast without password protection" switch (terminal compose bar, issue #3488). */
|
||||
export const STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS = 'netcatty_terminal_broadcast_password_bypass_v1';
|
||||
|
||||
@@ -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",
|
||||
|
||||
+592
-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,256 @@
|
||||
"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, execFile, spawn } = require("node:child_process");
|
||||
const execFileAsync = require("node:util").promisify(execFile);
|
||||
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();
|
||||
// A selected SK handle remains usable without its optional .pub file.
|
||||
fs.unlinkSync(`${keyPath}.pub`);
|
||||
// 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]));
|
||||
const nativeAgents = await Promise.all([sender, otherSender].map(owner => prepareSystemSshAgentForAuth({
|
||||
...options, nativeFidoAgent: true, resolveWebContents: () => owner,
|
||||
}, "[NativeFidoWindow]")));
|
||||
try {
|
||||
const beforeNative = routedPrompts.length;
|
||||
const results = await Promise.all(nativeAgents.map(nativeAgent => execFileAsync("ssh", [
|
||||
"-o", `IdentityAgent=${nativeAgent._netcattyAgentSocket}`,
|
||||
"-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes", "-o", "CertificateFile=none",
|
||||
"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
|
||||
"-i", keyPath, "-p", String(port), `${username}@127.0.0.1`, "printf native-fido-ok",
|
||||
], { timeout: 15000 })));
|
||||
for (const result of results) assert.equal(result.stdout, "native-fido-ok");
|
||||
assert.deepEqual(new Set(routedPrompts.slice(beforeNative)), new Set([sender.id, otherSender.id]));
|
||||
} finally { nativeAgents.forEach(nativeAgent => nativeAgent._releaseNetcattyFidoAgent?.()); }
|
||||
// A second certificate for an owned bare key needs its own hold.
|
||||
// Closing it must remove that certificate and retain the first.
|
||||
const extraPubPath = path.join(dir, `${type}-extra.pub`);
|
||||
fs.writeFileSync(extraPubPath, generated.publicKey);
|
||||
execFileSync("ssh-keygen", ["-s", path.join(dir, "host"), "-I", "fido-extra", "-z", "2",
|
||||
"-n", username, "-V", "-1m:+5m", "-O", "verify-required", extraPubPath]);
|
||||
const extraCertificate = fs.readFileSync(extraPubPath.replace(/\.pub$/, "-cert.pub"), "utf8").trim();
|
||||
const extraAgent = await prepareSystemSshAgentForAuth({
|
||||
...options, privateKey: generated.privateKey, identityFilePaths: [], certificate: extraCertificate,
|
||||
}, "[ExtraFidoCertificate]");
|
||||
const sharedSocket = require("../electron/bridges/fidoAgentManager.cjs").getActiveFidoAgentSocket();
|
||||
extraAgent._releaseNetcattyFidoAgent();
|
||||
await require("../electron/bridges/systemSshAgent.cjs").withAgentIdentityLock(sharedSocket, async () => {});
|
||||
const remaining = (await execFileAsync("ssh-add", ["-L"], {
|
||||
env: { ...process.env, SSH_AUTH_SOCK: sharedSocket },
|
||||
})).stdout;
|
||||
assert.ok(remaining.includes(generated.publicKey.split(" ")[1]), "bare identity remains in use");
|
||||
assert.ok(remaining.includes(certificate.split(" ")[1]), "other session's certificate remains in use");
|
||||
assert.ok(!remaining.includes(extraCertificate.split(" ")[1]), "released certificate is removed");
|
||||
} 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, nativeFidoAgent: true,
|
||||
...(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");
|
||||
const nativeCertificate = await execFileAsync("ssh", [
|
||||
"-o", `IdentityAgent=${certAgent._netcattyAgentSocket}`, "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes",
|
||||
"-o", `CertificateFile=${keyPath}-cert.pub`, "-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null", "-i", keyPath, "-p", String(port),
|
||||
`${username}@127.0.0.1`, "printf native-cert-ok",
|
||||
], { timeout: 15000 });
|
||||
assert.equal(nativeCertificate.stdout, "native-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,270 @@
|
||||
"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 { createAgent } = require("ssh2/lib/agent.js");
|
||||
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 manager = 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 originalGetTempDir = tempDirBridge.getTempDir;
|
||||
const originalAcquire = manager.acquireFidoAgent;
|
||||
const spacedTemp = path.join(dir, "profile space");
|
||||
fs.mkdirSync(spacedTemp);
|
||||
tempDirBridge.getTempDir = () => spacedTemp;
|
||||
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.write("windows-fido-ok");
|
||||
stream.exit(0);
|
||||
stream.end();
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
async function connect(generated, password, nativeAgent = false) {
|
||||
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,
|
||||
nativeFidoAgent: nativeAgent,
|
||||
}, 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: nativeAgent ? createAgent(agent._netcattyAgentSocket) : 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;
|
||||
manager.acquireFidoAgent = options => originalAcquire({ ...options, execFile: async (binary, args, options) => {
|
||||
try { return await run(binary, args, options); }
|
||||
catch (error) {
|
||||
// Agent startup has no secret input; surface binding errors in CI.
|
||||
console.log("[WindowsFido] agent startup rejected", args, error.stderr || error.message);
|
||||
throw error;
|
||||
}
|
||||
} });
|
||||
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, undefined, true);
|
||||
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 runtime of ["MSYS", "Win32"]) {
|
||||
await t.test(`${type}: real ${runtime} OpenSSH logs in through its native socket`, async () => {
|
||||
const sshPath = runtime === "MSYS"
|
||||
? path.join(path.dirname(process.env.NETCATTY_WINDOWS_SSH_AGENT), "ssh.exe")
|
||||
: path.join(process.env.SystemRoot, "System32", "OpenSSH", "ssh.exe");
|
||||
assert.ok(fs.existsSync(sshPath), `${runtime} SSH client must be installed`);
|
||||
const before = seen.length;
|
||||
fs.writeFileSync(control, "touch");
|
||||
const options = await enhanceAuthOptionsForFido({
|
||||
privateKey: generated.privateKey, identitiesOnly: true,
|
||||
nativeFidoAgent: true, nativeSshPath: sshPath,
|
||||
}, sender);
|
||||
const agent = await prepareSystemSshAgentForAuth(options, "[WindowsNativeFido]");
|
||||
try {
|
||||
const identityAgent = agent._netcattyAgentSocket;
|
||||
assert.equal(identityAgent.startsWith("\\\\.\\pipe"), runtime === "Win32");
|
||||
const result = await run(sshPath, ["-F", "none", "-p", String(server.address().port),
|
||||
"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=NUL",
|
||||
"-o", "PreferredAuthentications=publickey", "-o", "BatchMode=yes",
|
||||
"-o", `IdentityAgent=${JSON.stringify(identityAgent)}`, "netcatty@127.0.0.1", "probe"],
|
||||
{ env: { ...process.env, SSH_AUTH_SOCK: identityAgent }, timeout: 15000, windowsHide: true });
|
||||
assert.equal(result.stdout, "windows-fido-ok");
|
||||
assert.ok(seen.slice(before).some(request => request.kind === "pin"));
|
||||
} finally { agent._releaseNetcattyFidoAgent?.(); }
|
||||
});
|
||||
}
|
||||
|
||||
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, 6, "server must verify both SK algorithms through ssh2, MSYS SSH, and Win32 SSH");
|
||||
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();
|
||||
tempDirBridge.getTempDir = originalGetTempDir;
|
||||
manager.acquireFidoAgent = originalAcquire;
|
||||
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;
|
||||
}
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user