Files
NetMesh/electron/bridges/passphraseHandler.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

220 lines
6.5 KiB
JavaScript

/**
* Passphrase Handler - Handles passphrase requests for encrypted SSH keys
* This module provides a mechanism to request passphrase input from the user
* when encountering encrypted default SSH keys in ~/.ssh
*/
// Passphrase request pending map
// Map of requestId -> { resolveCallback, webContentsId, keyPath, createdAt, timeoutId, sender, signal, abortHandler }
const { randomUUID } = require("node:crypto");
const passphraseRequests = new Map();
// TTL for abandoned requests (2 minutes)
const REQUEST_TTL_MS = 2 * 60 * 1000;
/**
* Generate a unique request ID for passphrase requests
*/
function generateRequestId(prefix = 'pp') {
return `${prefix}-${randomUUID()}`;
}
/**
* Request passphrase from user via IPC
* @param {Object} sender - Electron webContents sender
* @param {string} keyPath - Path to the encrypted key
* @param {string} keyName - Name of the key (e.g., id_rsa)
* @param {string} [hostname] - Optional hostname for context
* @returns {Promise<{ passphrase?: string, cancelled?: boolean, skipped?: boolean } | null>}
*/
function settleRequest(requestId, result, notification) {
const pending = passphraseRequests.get(requestId);
if (!pending) return false;
if (pending.timeoutId) {
clearTimeout(pending.timeoutId);
}
if (pending.signal && pending.abortHandler) {
pending.signal.removeEventListener("abort", pending.abortHandler);
}
passphraseRequests.delete(requestId);
if (notification) {
try {
if (!pending.sender?.isDestroyed?.()) {
pending.sender?.send?.(notification.channel, {
requestId,
...(notification.payload || {}),
});
}
} catch (err) {
console.warn(`[Passphrase] Failed to send ${notification.channel} notification:`, err.message);
}
}
pending.resolveCallback(result);
return true;
}
function cancelPassphraseRequest(requestId, reason = "cancelled") {
const cancelled = settleRequest(
requestId,
{ cancelled: true },
{
channel: "netcatty:passphrase-cancelled",
payload: { reason },
}
);
if (cancelled) {
console.log(`[Passphrase] Request ${requestId} cancelled by ${reason}`);
}
return cancelled;
}
function cancelPassphraseRequestsForSession(sessionId, reason = "session-closed", bootEpoch) {
if (!sessionId) return 0;
const requestedEpoch = Number.isFinite(bootEpoch) ? Number(bootEpoch) : undefined;
let cancelled = 0;
for (const [requestId, pending] of [...passphraseRequests.entries()]) {
if (pending.sessionId !== sessionId) continue;
// A stale close for an older boot must not cancel a newer reconnect's prompt.
if (
requestedEpoch !== undefined
&& Number.isFinite(pending.bootEpoch)
&& pending.bootEpoch > requestedEpoch
) {
continue;
}
if (cancelPassphraseRequest(requestId, reason)) cancelled += 1;
}
return cancelled;
}
function requestPassphrase(sender, keyPath, keyName, hostname, passphraseInvalid, options = {}) {
return new Promise((resolve) => {
if (!sender || sender.isDestroyed()) {
console.warn('[Passphrase] Sender is destroyed, cannot request passphrase');
resolve(null);
return;
}
const signal = options?.signal;
if (signal?.aborted) {
resolve({ cancelled: true });
return;
}
const requestId = generateRequestId();
// Set up TTL timeout to clean up abandoned requests
const timeoutId = setTimeout(() => {
if (passphraseRequests.has(requestId)) {
console.warn(`[Passphrase] Request ${requestId} timed out after ${REQUEST_TTL_MS / 1000}s`);
settleRequest(
requestId,
null,
{ channel: "netcatty:passphrase-timeout" }
);
}
}, REQUEST_TTL_MS);
const abortHandler = () => {
cancelPassphraseRequest(requestId, "external-cancel");
};
passphraseRequests.set(requestId, {
resolveCallback: resolve,
sender,
webContentsId: sender.id,
keyPath,
keyName,
sessionId: typeof options.sessionId === "string" ? options.sessionId : undefined,
bootEpoch: Number.isFinite(options.bootEpoch) ? Number(options.bootEpoch) : undefined,
createdAt: Date.now(),
timeoutId,
signal,
abortHandler: signal ? abortHandler : null,
});
if (signal) {
signal.addEventListener("abort", abortHandler, { once: true });
}
console.log(`[Passphrase] Requesting passphrase for ${keyName} (${requestId})`);
try {
sender.send('netcatty:passphrase-request', {
requestId,
keyPath,
keyName,
hostname,
passphraseInvalid: !!passphraseInvalid,
...(typeof options.sessionId === "string" ? { sessionId: options.sessionId } : {}),
...(Number.isFinite(options.bootEpoch) ? { bootEpoch: Number(options.bootEpoch) } : {}),
});
} catch (err) {
console.error('[Passphrase] Failed to send passphrase request:', err);
settleRequest(requestId, null);
}
});
}
/**
* Handle passphrase response from renderer
*/
function handleResponse(_event, payload) {
const { requestId, passphrase, cancelled, skipped } = payload;
const pending = passphraseRequests.get(requestId);
if (!pending) {
console.warn(`[Passphrase] No pending request for ${requestId}`);
return { success: false, error: 'Request not found' };
}
if (_event?.sender?.id !== pending.webContentsId) {
console.warn(`[Passphrase] Wrong sender for request ${requestId}`);
return { success: false, error: 'Wrong sender' };
}
if (cancelled) {
// User clicked Cancel - stop the entire passphrase flow
console.log(`[Passphrase] Request ${requestId} cancelled by user`);
settleRequest(requestId, { cancelled: true });
} else if (skipped) {
// User clicked Skip - skip this key but continue with others
console.log(`[Passphrase] Request ${requestId} skipped by user`);
settleRequest(requestId, { skipped: true });
} else {
console.log(`[Passphrase] Received passphrase for ${requestId}`);
settleRequest(requestId, { passphrase: passphrase || null });
}
return { success: true };
}
/**
* Register IPC handler for passphrase responses
*/
function registerHandler(ipcMain) {
ipcMain.handle('netcatty:passphrase:respond', handleResponse);
}
/**
* Get pending requests (for debugging)
*/
function getRequests() {
return passphraseRequests;
}
module.exports = {
generateRequestId,
requestPassphrase,
cancelPassphraseRequest,
cancelPassphraseRequestsForSession,
handleResponse,
registerHandler,
getRequests,
};