[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
219
electron/bridges/passphraseHandler.cjs
Normal file
219
electron/bridges/passphraseHandler.cjs
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user