mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 22:40:09 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b81d5cdba3 | ||
|
|
0be0f29f15 | ||
|
|
e94c88a929 | ||
|
|
e59701f3b3 | ||
|
|
e17dd93d20 | ||
|
|
f818b8417e | ||
|
|
304646efdf |
No files matched your search
@@ -113,7 +113,7 @@ jobs:
|
||||
- name: Exercise Windows pipes, PIN bridge, SK signatures and cancellation
|
||||
env:
|
||||
NETCATTY_FIDO_WINDOWS: "1"
|
||||
run: node --test scripts/fido-windows.live.test.cjs
|
||||
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
|
||||
|
||||
@@ -31,11 +31,27 @@ 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 exercises Windows named-pipe askpass IPC, enrollment and actual signatures
|
||||
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
|
||||
|
||||
@@ -117,10 +117,10 @@ function resolveSshAgentBinary(env = process.env, platform = process.platform) {
|
||||
}
|
||||
|
||||
function parseAgentStdout(stdout) {
|
||||
const sockMatch = /SSH_AUTH_SOCK=([^;\s]+)/.exec(stdout || "");
|
||||
const sockMatch = /SSH_AUTH_SOCK=([^;\r\n]+)/.exec(stdout || "");
|
||||
const pidMatch = /SSH_AGENT_PID=(\d+)/.exec(stdout || "");
|
||||
return {
|
||||
socketPath: sockMatch?.[1] || null,
|
||||
socketPath: sockMatch?.[1]?.trim().replace(/^(['"])(.*)\1$/, "$2") || null,
|
||||
agentPid: pidMatch ? Number(pidMatch[1]) : null,
|
||||
};
|
||||
}
|
||||
@@ -230,7 +230,9 @@ async function acquireFidoAgent(options = {}) {
|
||||
// Caller-specific askpass lease — never reuse the starter's resolver.
|
||||
return {
|
||||
socketPath: shared.socketPath,
|
||||
askpassEnv: buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents }),
|
||||
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,
|
||||
@@ -242,7 +244,9 @@ async function acquireFidoAgent(options = {}) {
|
||||
refCount += 1;
|
||||
return {
|
||||
socketPath: agentSocket,
|
||||
askpassEnv: buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents }),
|
||||
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,
|
||||
@@ -256,8 +260,10 @@ async function acquireFidoAgent(options = {}) {
|
||||
startingPromise = (async () => {
|
||||
const run = options.execFile || execFileAsync;
|
||||
const env = options.env || process.env;
|
||||
const askpassEnv = buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents });
|
||||
const platform = options.platform || process.platform;
|
||||
const sshAgent = resolveSshAgentBinary(env, platform);
|
||||
const askpassEnv = buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents,
|
||||
sshBinaryPath: sshAgent, env });
|
||||
const releaseStarterAskpassLease = () => {
|
||||
try {
|
||||
releaseFidoAskpassLease(askpassEnv?.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
@@ -267,10 +273,11 @@ async function acquireFidoAgent(options = {}) {
|
||||
};
|
||||
|
||||
try {
|
||||
const sshAgent = resolveSshAgentBinary(env, platform);
|
||||
const sshAddPath = companionSshAddPath(sshAgent);
|
||||
|
||||
agentDir = path.join(getTempBase(), `netcatty-fido-agent-${randomUUID().slice(0, 8)}`);
|
||||
// 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,
|
||||
@@ -285,7 +292,7 @@ async function acquireFidoAgent(options = {}) {
|
||||
const sockCandidates = platform === "win32"
|
||||
? [
|
||||
`\\\\.\\pipe\\netcatty-fido-agent-${randomUUID().slice(0, 8)}`,
|
||||
path.join(agentDir, "agent.sock"),
|
||||
path.join(agentDir, "agent.sock").replace(/\\/g, "/"),
|
||||
]
|
||||
: [path.join(agentDir, "agent.sock")];
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ test("parseAgentStdout extracts sock and pid", () => {
|
||||
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();
|
||||
|
||||
@@ -182,7 +182,7 @@ function resolveFidoAskpassSocketPath(baseDir, platform = process.platform) {
|
||||
if (platform === "win32") {
|
||||
return `\\\\.\\pipe\\netcatty-fido-askpass-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
return path.join(baseDir, "askpass.sock");
|
||||
return path.posix.join(baseDir, "askpass.sock");
|
||||
}
|
||||
|
||||
function writeSecureFile(filePath, contents, mode = 0o700) {
|
||||
@@ -243,7 +243,7 @@ function resolveSharedAgentPromptSender() {
|
||||
|| liveSenderFromResolver(resolveWebContents);
|
||||
}
|
||||
|
||||
function handleAskpassClient(socket) {
|
||||
function handleAskpassClient(socket, platform = process.platform) {
|
||||
let buf = "";
|
||||
let handled = false;
|
||||
const controller = new AbortController();
|
||||
@@ -302,14 +302,28 @@ function handleAskpassClient(socket) {
|
||||
}
|
||||
const signing = signingQueue[0]?.leaseId === ownerLeaseId ? signingQueue[0] : null;
|
||||
try {
|
||||
const result = await fidoPromptHandler.requestFidoPrompt(sender, {
|
||||
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: controller.signal,
|
||||
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;
|
||||
@@ -319,14 +333,16 @@ function handleAskpassClient(socket) {
|
||||
// Touch/confirm: empty string is fine; PIN: return entered secret.
|
||||
const response = kind === "pin" ? (result.response || "") : "";
|
||||
if (kind === "pin" && signing) signing.pinSubmitted = true;
|
||||
if (kind === "pin" && response && /user presence|touch|tap/i.test(prompt)
|
||||
&& signing?.leaseId === ownerLeaseId && !signing.controller.signal.aborted) {
|
||||
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,
|
||||
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`);
|
||||
@@ -360,6 +376,12 @@ function ensureFidoAskpass(options = {}) {
|
||||
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(
|
||||
@@ -368,14 +390,7 @@ function ensureFidoAskpass(options = {}) {
|
||||
0o700,
|
||||
);
|
||||
} else {
|
||||
askpassWrapperPath = path.join(base, "netcatty-fido-askpass.sh");
|
||||
const electronExec = JSON.stringify(process.execPath);
|
||||
const scriptExec = JSON.stringify(askpassScriptPath);
|
||||
writeSecureFile(
|
||||
askpassWrapperPath,
|
||||
`#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${electronExec} ${scriptExec} "$@"\n`,
|
||||
0o700,
|
||||
);
|
||||
askpassWrapperPath = posixWrapperPath;
|
||||
}
|
||||
|
||||
if (!isWindowsNamedPipePath(askpassSocketPath) && fs.existsSync(askpassSocketPath)) {
|
||||
@@ -402,9 +417,31 @@ function ensureFidoAskpass(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
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 }} [options]
|
||||
* @param {{ resolveWebContents?: () => import("electron").WebContents|null, sshBinaryPath?: string, env?: NodeJS.ProcessEnv }} [options]
|
||||
*/
|
||||
function buildFidoAskpassEnv(options = {}) {
|
||||
const artifacts = ensureFidoAskpass(options);
|
||||
@@ -413,7 +450,7 @@ function buildFidoAskpassEnv(options = {}) {
|
||||
askpassLeases.set(leaseId, options.resolveWebContents);
|
||||
}
|
||||
const env = {
|
||||
SSH_ASKPASS: artifacts.wrapperPath,
|
||||
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,
|
||||
@@ -485,6 +522,8 @@ module.exports = {
|
||||
classifyAskpassPrompt: fidoPromptHandler.classifyAskpassPrompt,
|
||||
resolveFidoAskpassSocketPath,
|
||||
isWindowsNamedPipePath,
|
||||
selectFidoAskpassWrapper,
|
||||
isWindowsPosixSsh,
|
||||
// exposed for tests
|
||||
getTempBase,
|
||||
handleAskpassClient,
|
||||
|
||||
@@ -14,7 +14,7 @@ const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
// Keep the managed temp path short: Unix domain sockets reject long sun_path.
|
||||
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fp-"));
|
||||
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fp 'q$-"));
|
||||
const originalGetTempDir = tempDirBridge.getTempDir;
|
||||
tempDirBridge.getTempDir = () => managedTemp;
|
||||
const {
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
} = 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();
|
||||
@@ -54,3 +55,35 @@ test("releaseFidoAskpassLease cancels outstanding FIDO prompts for that lease",
|
||||
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(); }
|
||||
});
|
||||
@@ -6,13 +6,27 @@ 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), false);
|
||||
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", () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -66,11 +66,12 @@ function cancelFidoPromptRequest(requestId, reason = "cancelled") {
|
||||
* @param {string} [reason]
|
||||
* @returns {number}
|
||||
*/
|
||||
function cancelFidoPromptRequestsForLease(leaseId, reason = "lease-released") {
|
||||
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;
|
||||
@@ -116,6 +117,7 @@ function requestFidoPrompt(sender, options = {}) {
|
||||
webContentsId: sender.id,
|
||||
kind,
|
||||
leaseId,
|
||||
notificationOnly: options.notificationOnly === true,
|
||||
createdAt: Date.now(),
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
@@ -90,6 +90,8 @@ async function generateFidoSshKeyPair(options = {}) {
|
||||
const { buildFidoAskpassEnv } = require("./fidoAskpass.cjs");
|
||||
askpassEnv = buildFidoAskpassEnv({
|
||||
resolveWebContents: options.resolveWebContents,
|
||||
sshBinaryPath: sshKeygen,
|
||||
env,
|
||||
});
|
||||
askpassLeaseId = askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE || null;
|
||||
} catch {
|
||||
|
||||
+191
-189
@@ -1086,21 +1086,26 @@ async function materializeSkPrivateKeyFile(privateKey, injected = {}) {
|
||||
}
|
||||
const baseDir = tempDirBridge.getTempDir();
|
||||
const dir = await fsMod.promises.mkdtemp(pathMod.join(baseDir, "netcatty-sk-key-"));
|
||||
const keyPath = pathMod.join(dir, "id_sk");
|
||||
await fsMod.promises.writeFile(keyPath, privateKey, { mode: 0o600 });
|
||||
// Companion .pub lets shared-agent prep recognize an already-loaded identity
|
||||
// across unique per-session temp paths (refcount cleanup keys on the blob).
|
||||
const publicKeyHint = typeof injected.publicKey === "string" ? injected.publicKey.trim() : "";
|
||||
const publicKeyLine = publicKeyHint || deriveSkPublicKeyLine(privateKey);
|
||||
if (publicKeyLine) {
|
||||
await fsMod.promises.writeFile(`${keyPath}.pub`, `${publicKeyLine}\n`, { mode: 0o600 });
|
||||
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;
|
||||
}
|
||||
const certificate = typeof injected.certificate === "string" ? injected.certificate.trim() : "";
|
||||
if (certificate) {
|
||||
// OpenSSH ssh-add auto-loads `<key>-cert.pub` next to the private handle.
|
||||
await fsMod.promises.writeFile(`${keyPath}-cert.pub`, `${certificate}\n`, { mode: 0o600 });
|
||||
}
|
||||
return { keyPath, cleanupDir: dir };
|
||||
}
|
||||
|
||||
async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
@@ -1204,66 +1209,16 @@ async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
? options.agentPublicKeys.find((key) => looksLikeSkOpenSshMaterial(key))
|
||||
: null;
|
||||
let skTempCleanup = null;
|
||||
if (looksLikeSkOpenSshMaterial(options.privateKey)) {
|
||||
try {
|
||||
const materialized = await materializeSkPrivateKeyFile(options.privateKey, {
|
||||
certificate: options.certificate,
|
||||
publicKey: publicKeyHint,
|
||||
});
|
||||
if (materialized?.keyPath) {
|
||||
identityFilePaths.push(materialized.keyPath);
|
||||
skTempCleanup = materialized.cleanupDir;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`${logPrefix} Could not materialize FIDO2 key handle for agent load`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
isFidoFlow
|
||||
&& typeof options.certificate === "string"
|
||||
&& options.certificate.trim()
|
||||
&& Array.isArray(options.identityFilePaths)
|
||||
) {
|
||||
// Path-backed SK + vault certificate: copy handle into temp and stage
|
||||
// `<key>-cert.pub` so ssh-add advertises the certified identity. Never
|
||||
// mutate the user's IdentityFile tree.
|
||||
try {
|
||||
for (const rawPath of options.identityFilePaths) {
|
||||
const identityPath = expandIdentityFilePath(rawPath, options);
|
||||
if (!identityPath || identityPath.endsWith(".pub")) continue;
|
||||
let privateKeyText = "";
|
||||
try {
|
||||
privateKeyText = await fs.promises.readFile(identityPath, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!looksLikeSkOpenSshMaterial(privateKeyText)) continue;
|
||||
const materialized = await materializeSkPrivateKeyFile(privateKeyText, {
|
||||
certificate: options.certificate,
|
||||
publicKey: publicKeyHint,
|
||||
});
|
||||
if (materialized?.keyPath) {
|
||||
identityFilePaths.push(materialized.keyPath);
|
||||
skTempCleanup = materialized.cleanupDir;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`${logPrefix} Could not stage FIDO certificate for agent load`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
let newlyLoadedIdentityPaths = [];
|
||||
/** @type {Array<{ key: string, identityPath: string }>} */
|
||||
let sharedAgentIdentities = [];
|
||||
let fidoResourcesReleased = false;
|
||||
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
|
||||
@@ -1276,40 +1231,27 @@ async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
releaseSharedAgentIdentity,
|
||||
} = require("./systemSshAgent.cjs");
|
||||
const sshAdd = fidoSshAddPath || resolveSshAddBinary(process.platform, process.env);
|
||||
for (const entry of sharedAgentIdentities) {
|
||||
const { shouldRemove, identityPath, cleanupDir } = releaseSharedAgentIdentity(entry.key);
|
||||
if (!shouldRemove) continue;
|
||||
const removeCleanupDir = () => {
|
||||
if (!cleanupDir) return;
|
||||
fs.promises.rm(cleanupDir, { recursive: true, force: true }).catch(() => {});
|
||||
};
|
||||
if (identityPath) {
|
||||
try {
|
||||
// ssh-add -d treats operands as identity-file paths; keep the
|
||||
// staging dir until the child finishes (or fails to spawn).
|
||||
childProcess.execFile(
|
||||
sshAdd,
|
||||
["-d", identityPath],
|
||||
{
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
SSH_AUTH_SOCK: socketPath,
|
||||
SSH_ASKPASS_REQUIRE: "never",
|
||||
},
|
||||
},
|
||||
() => {
|
||||
removeCleanupDir();
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
removeCleanupDir();
|
||||
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(); }
|
||||
});
|
||||
}
|
||||
} else {
|
||||
removeCleanupDir();
|
||||
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).
|
||||
@@ -1330,103 +1272,163 @@ async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
};
|
||||
|
||||
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;
|
||||
const agent = await systemSshAgent.prepareSystemSshAgent({
|
||||
socketPath,
|
||||
identityFilePaths,
|
||||
identitiesOnly: options.identitiesOnly,
|
||||
addKeysToAgent: options.addKeysToAgent,
|
||||
useKeychain: ownedFidoAgent ? false : options.useKeychain,
|
||||
agentPublicKeys: isFidoFlow && options.certificate
|
||||
? [...(options.agentPublicKeys || []), options.certificate]
|
||||
: options.agentPublicKeys,
|
||||
loadIdentityFilesIntoAgent: looksLikeSkOpenSshMaterial(options.privateKey)
|
||||
|| (Array.isArray(options.agentPublicKeys)
|
||||
&& options.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key)))
|
||||
|| options.loadIdentityFilesIntoAgent === true
|
||||
|| ownedFidoAgent
|
||||
// Windows system-pipe fallback still needs SK handles loaded for ssh2.
|
||||
|| (!ownedFidoAgent && acquiredFidoAgent),
|
||||
hostname: options.hostname,
|
||||
port: options.port,
|
||||
username: options.username,
|
||||
}, {
|
||||
log: (message, details) => console.log(`${logPrefix} ${message}`, details ?? ""),
|
||||
askpassEnv,
|
||||
env: prepEnv,
|
||||
});
|
||||
if (agent) {
|
||||
// Expose the socket so Mosh/ET/native OpenSSH can point IdentityAgent at
|
||||
// the same agent that received ssh-add of SK handles.
|
||||
agent._netcattyAgentSocket = socketPath;
|
||||
newlyLoadedIdentityPaths = Array.isArray(agent._netcattyNewlyLoadedIdentityPaths)
|
||||
? agent._netcattyNewlyLoadedIdentityPaths
|
||||
: [];
|
||||
sharedAgentIdentities = Array.isArray(agent._netcattySharedAgentIdentities)
|
||||
? agent._netcattySharedAgentIdentities
|
||||
: newlyLoadedIdentityPaths.map((identityPath) => ({
|
||||
key: identityPath,
|
||||
identityPath,
|
||||
}));
|
||||
if (sharedAgentIdentities.length > 0) {
|
||||
// Reference-count identities for owned agents too: the owned agent is
|
||||
// a long-lived singleton, so closing one session must drop only that
|
||||
// session's hold, not leave its SK identity advertised to survivors.
|
||||
const { retainSharedAgentIdentity } = require("./systemSshAgent.cjs");
|
||||
let transferredSkTempCleanup = false;
|
||||
for (const entry of sharedAgentIdentities) {
|
||||
const cleanupDir = skTempCleanup
|
||||
&& typeof entry.identityPath === "string"
|
||||
&& entry.identityPath.startsWith(skTempCleanup)
|
||||
? skTempCleanup
|
||||
: null;
|
||||
const retained = retainSharedAgentIdentity(entry.key, entry.identityPath, cleanupDir);
|
||||
// Only the first retainer for a public identity adopts staging cleanup.
|
||||
// Later sessions materialize a redundant temp dir that must stay on
|
||||
// skTempCleanup so the finally block can remove it immediately.
|
||||
if (retained?.acceptedCleanup) {
|
||||
transferredSkTempCleanup = true;
|
||||
}
|
||||
}
|
||||
if (transferredSkTempCleanup) {
|
||||
skTempCleanup = null;
|
||||
}
|
||||
}
|
||||
const askpassLeaseId = askpassEnv?.NETCATTY_FIDO_ASKPASS_LEASE;
|
||||
if (askpassLeaseId) {
|
||||
const { runWithFidoSigningLease } = require("./fidoAskpass.cjs");
|
||||
const sign = agent.sign.bind(agent);
|
||||
agent.sign = (key, data, signOptions, callback) => {
|
||||
if (typeof signOptions === "function") {
|
||||
callback = signOptions;
|
||||
signOptions = undefined;
|
||||
}
|
||||
runWithFidoSigningLease(askpassLeaseId, (finish, context) => {
|
||||
if (fidoResourcesReleased) {
|
||||
finish();
|
||||
callback(new Error("FIDO authentication session has ended"));
|
||||
return;
|
||||
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;
|
||||
}
|
||||
require("./fidoSignFlow.cjs").signWithRecovery(context, options,
|
||||
settle => sign(key, data, signOptions, settle)).then(result => {
|
||||
}
|
||||
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(result.error, result.signature);
|
||||
}, error => { finish(); callback(error); });
|
||||
});
|
||||
};
|
||||
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();
|
||||
}
|
||||
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;
|
||||
return agent;
|
||||
});
|
||||
} catch (error) {
|
||||
// prepareSystemSshAgent can throw after acquire (e.g. IdentitiesOnly without
|
||||
// a readable .pub selector); release owned agent + askpass before rethrowing.
|
||||
|
||||
@@ -189,6 +189,59 @@ test("materializeSkPrivateKeyFile fails closed without managed temp dir", async
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -307,8 +360,10 @@ test("owned FIDO agent reference-counts identities: ssh-add -d only on the last
|
||||
|
||||
// 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;
|
||||
@@ -378,6 +433,7 @@ test("prepareSystemSshAgentForAuth removes newly loaded identities from a shared
|
||||
assert.ok(agent);
|
||||
assert.equal(typeof agent._releaseNetcattyFidoAgent, "function");
|
||||
agent._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [identityPath]);
|
||||
assert.equal(releasedAgent, 1);
|
||||
} finally {
|
||||
@@ -462,8 +518,10 @@ test("shared Windows agent defers ssh-add -d until the last identity user releas
|
||||
}, "[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;
|
||||
@@ -505,8 +563,8 @@ test("shared Windows agent keeps staging dir until ssh-add -d finishes", async (
|
||||
fidoAskpass.releaseFidoAskpassLease = () => {};
|
||||
// Prep is mocked (no real ssh-add); still attach staging cleanup the way
|
||||
// materializeSkPrivateKeyFile + retainSharedAgentIdentity would in production.
|
||||
systemSshAgent.retainSharedAgentIdentity = (key, idPath, cleanupDir) => (
|
||||
originalRetain(key, idPath, cleanupDir || stagingDir)
|
||||
systemSshAgent.retainSharedAgentIdentity = (key, idPath, cleanupDir, socketPath) => (
|
||||
originalRetain(key, idPath, cleanupDir || stagingDir, socketPath)
|
||||
);
|
||||
systemSshAgent.prepareSystemSshAgent = async () => ({
|
||||
getIdentities: (cb) => cb(null, []),
|
||||
@@ -535,6 +593,7 @@ test("shared Windows agent keeps staging dir until ssh-add -d finishes", async (
|
||||
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),
|
||||
|
||||
@@ -264,7 +264,11 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
}
|
||||
|
||||
if (!agent && auth.useSshAgent && typeof prepareSystemSshAgentForAuth === "function") {
|
||||
const prepared = 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
|
||||
|
||||
@@ -385,6 +385,24 @@ test("path-backed sk-* key handles are filtered out of the companion's identity
|
||||
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
|
||||
|
||||
@@ -25,23 +25,51 @@ 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 }>}
|
||||
* @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) {
|
||||
function retainSharedAgentIdentity(key, identityPath, cleanupDir = null, socketPath = "", plain = false) {
|
||||
if (typeof key !== "string" || !key || typeof identityPath !== "string" || !identityPath) {
|
||||
return null;
|
||||
}
|
||||
const existing = sharedAgentIdentityRefs.get(key);
|
||||
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.
|
||||
@@ -52,20 +80,24 @@ function retainSharedAgentIdentity(key, identityPath, cleanupDir = null) {
|
||||
refCount: 1,
|
||||
identityPath,
|
||||
cleanupDir: normalizedCleanup,
|
||||
...(plain ? { plain: true } : {}),
|
||||
};
|
||||
sharedAgentIdentityRefs.set(key, entry);
|
||||
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) {
|
||||
function releaseSharedAgentIdentity(key, socketPath = "") {
|
||||
if (typeof key !== "string" || !key) {
|
||||
return { shouldRemove: false, identityPath: null, cleanupDir: null };
|
||||
}
|
||||
const existing = sharedAgentIdentityRefs.get(key);
|
||||
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) {
|
||||
@@ -75,9 +107,10 @@ function releaseSharedAgentIdentity(key) {
|
||||
cleanupDir: existing.cleanupDir,
|
||||
};
|
||||
}
|
||||
sharedAgentIdentityRefs.delete(key);
|
||||
sharedAgentIdentityRefs.delete(refKey);
|
||||
return {
|
||||
shouldRemove: true,
|
||||
...(existing.plain ? { plain: true } : {}),
|
||||
identityPath: existing.identityPath,
|
||||
cleanupDir: existing.cleanupDir,
|
||||
};
|
||||
@@ -87,12 +120,13 @@ function releaseSharedAgentIdentity(key) {
|
||||
* @param {string} key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasSharedAgentIdentity(key) {
|
||||
return typeof key === "string" && key.length > 0 && sharedAgentIdentityRefs.has(key);
|
||||
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 = {}) {
|
||||
@@ -133,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;
|
||||
@@ -151,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,
|
||||
@@ -161,7 +205,7 @@ async function loadPreferredPublicKeyBlobs(identityFilePaths, publicKeys, option
|
||||
});
|
||||
}
|
||||
}
|
||||
return { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths };
|
||||
return { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths, identityBlobs };
|
||||
}
|
||||
|
||||
function getIdentities(agent) {
|
||||
@@ -335,7 +379,7 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
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 },
|
||||
@@ -397,11 +441,12 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
const pubPath = identityPath.endsWith(".pub") ? identityPath : `${identityPath}.pub`;
|
||||
const certPath = identityPath.endsWith(".pub") ? null : `${identityPath}-cert.pub`;
|
||||
/** @type {string|null} */
|
||||
let blob = null;
|
||||
let blob = identityBlobs.get(identityPath) || null;
|
||||
/** @type {string|null} */
|
||||
let certBlob = null;
|
||||
let bareAlreadyPresent = false;
|
||||
let bareAlreadyPresent = Boolean(blob && loadedBlobs.has(blob));
|
||||
let companionCertificateMissing = false;
|
||||
let certificateAlreadyPresent = false;
|
||||
try {
|
||||
const pub = await deps.readFile(pubPath, "utf8");
|
||||
blob = publicKeyBlob(pub);
|
||||
@@ -409,14 +454,15 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
} catch {
|
||||
// No .pub selector — still attempt ssh-add when requested.
|
||||
}
|
||||
if (bareAlreadyPresent && certPath) {
|
||||
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);
|
||||
if (certBlob && !loadedBlobs.has(certBlob)) {
|
||||
certificateAlreadyPresent = Boolean(certBlob && loadedBlobs.has(certBlob));
|
||||
if (certBlob && !certificateAlreadyPresent) {
|
||||
companionCertificateMissing = true;
|
||||
}
|
||||
} catch {
|
||||
@@ -428,8 +474,11 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
// session loaded it). Pre-existing user agent identities must not be
|
||||
// adopted into cleanup — last release would ssh-add -d them.
|
||||
const key = blob || identityPath;
|
||||
if (hasSharedAgentIdentity(key)) {
|
||||
sharedAgentIdentities.push({ key, identityPath });
|
||||
if (hasSharedAgentIdentity(key, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key, identityPath, plain: true });
|
||||
}
|
||||
if (certBlob && hasSharedAgentIdentity(certBlob, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key: certBlob, identityPath: certPath });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -465,21 +514,17 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
}
|
||||
}
|
||||
const key = blob || identityPath;
|
||||
if (bareAlreadyPresent) {
|
||||
// Reloaded only to advertise a missing companion certificate.
|
||||
if (hasSharedAgentIdentity(key)) {
|
||||
// Another Netcatty session already owns the bare identity; join its
|
||||
// refcount so the last release can ssh-add -d the private handle.
|
||||
sharedAgentIdentities.push({ key, identityPath });
|
||||
} else if (certBlob && certPath) {
|
||||
// Bare key was already in the shared agent (user-managed). Track the
|
||||
// certificate alone so cleanup removes only what we added.
|
||||
newlyLoadedIdentityPaths.push(certPath);
|
||||
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 });
|
||||
}
|
||||
} else {
|
||||
newlyLoadedIdentityPaths.push(identityPath);
|
||||
sharedAgentIdentities.push({ key, identityPath });
|
||||
}
|
||||
deps.log?.("Loaded identity into SSH agent", { identityPath });
|
||||
} catch (error) {
|
||||
@@ -499,7 +544,8 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
// into a shared (e.g. Windows system) agent.
|
||||
for (const identityPath of newlyLoadedIdentityPaths) {
|
||||
try {
|
||||
const args = ["-d", identityPath];
|
||||
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, {
|
||||
@@ -534,6 +580,8 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
|
||||
module.exports = {
|
||||
IdentityAwareAgent,
|
||||
withAgentIdentityLock,
|
||||
finishSharedCleanupDir,
|
||||
prepareSystemSshAgent,
|
||||
publicKeyBlob,
|
||||
resolveIdentityPath,
|
||||
|
||||
@@ -10,6 +10,38 @@ 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`;
|
||||
@@ -124,7 +156,7 @@ test("prepareSystemSshAgent tracks newly loaded identities for shared-agent clea
|
||||
assert.deepEqual(sshAddCalls, [[identityPath]]);
|
||||
assert.deepEqual(prepared._netcattyNewlyLoadedIdentityPaths, [identityPath]);
|
||||
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
|
||||
{ key: publicKeyBlob(pubLine), identityPath },
|
||||
{ key: publicKeyBlob(pubLine), identityPath, plain: true },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -243,7 +275,7 @@ test("prepareSystemSshAgent ssh-adds when bare identity is loaded but companion
|
||||
};
|
||||
|
||||
try {
|
||||
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first");
|
||||
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first", null, "/tmp/fake-agent.sock");
|
||||
const prepared = await prepareSystemSshAgent({
|
||||
socketPath: "/tmp/fake-agent.sock",
|
||||
identityFilePaths: [identityPath],
|
||||
@@ -262,9 +294,10 @@ test("prepareSystemSshAgent ssh-adds when bare identity is loaded but companion
|
||||
assert.deepEqual(sshAddCalls, [[identityPath]]);
|
||||
// Bare key was already Netcatty-owned; join that refcount (do not treat
|
||||
// the cert-only reload as a newly loaded private identity).
|
||||
assert.equal(prepared._netcattyNewlyLoadedIdentityPaths, undefined);
|
||||
assert.deepEqual(prepared._netcattyNewlyLoadedIdentityPaths, [certPath]);
|
||||
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
|
||||
{ key: blob, identityPath },
|
||||
{ key: blob, identityPath, plain: true },
|
||||
{ key: certBlob, identityPath: certPath },
|
||||
]);
|
||||
} finally {
|
||||
resetSharedAgentIdentityRefsForTests();
|
||||
@@ -387,7 +420,7 @@ test("prepareSystemSshAgent joins Netcatty-owned already-loaded identities for s
|
||||
};
|
||||
|
||||
try {
|
||||
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first");
|
||||
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first", null, "/tmp/fake-agent.sock");
|
||||
const prepared = await prepareSystemSshAgent({
|
||||
socketPath: "/tmp/fake-agent.sock",
|
||||
identityFilePaths: [identityPath],
|
||||
@@ -406,7 +439,7 @@ test("prepareSystemSshAgent joins Netcatty-owned already-loaded identities for s
|
||||
|
||||
assert.equal(prepared._netcattyNewlyLoadedIdentityPaths, undefined);
|
||||
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
|
||||
{ key: blob, identityPath },
|
||||
{ key: blob, identityPath, plain: true },
|
||||
]);
|
||||
} finally {
|
||||
resetSharedAgentIdentityRefsForTests();
|
||||
|
||||
@@ -176,7 +176,7 @@ main();
|
||||
let releasePreparedAgent = null;
|
||||
try {
|
||||
if (connectionOptions.useSshAgent === true || forceFido) {
|
||||
const agent = await prepareSystemSshAgentForAuth(prepOptions, logPrefix);
|
||||
const agent = await prepareSystemSshAgentForAuth({ ...prepOptions, nativeFidoAgent: true, nativeSshPath: process.platform === "win32" ? findExecutable("ssh") : undefined }, logPrefix);
|
||||
if (typeof agent?._releaseNetcattyFidoAgent === "function") {
|
||||
releasePreparedAgent = agent._releaseNetcattyFidoAgent;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ function createMoshSessionApi(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareMoshSshAgentOptions(options, sender) {
|
||||
async function prepareMoshSshAgentOptions(options, sender, sshExe) {
|
||||
const {
|
||||
enhanceAuthOptionsForFido,
|
||||
resolvePreparedAgentSocket,
|
||||
@@ -158,7 +158,7 @@ function createMoshSessionApi(ctx) {
|
||||
let releasePreparedAgent = null;
|
||||
try {
|
||||
if (options.useSshAgent === true || forceFido) {
|
||||
const agent = await prepareSystemSshAgentForAuth(prepOptions, "[Mosh]");
|
||||
const agent = await prepareSystemSshAgentForAuth({ ...prepOptions, nativeFidoAgent: true, nativeSshPath: sshExe }, "[Mosh]");
|
||||
if (typeof agent?._releaseNetcattyFidoAgent === "function") {
|
||||
releasePreparedAgent = agent._releaseNetcattyFidoAgent;
|
||||
}
|
||||
@@ -412,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");
|
||||
}
|
||||
@@ -1011,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, event?.sender);
|
||||
const preparedOptions = await prepareMoshSshAgentOptions(options, event?.sender, sshExe);
|
||||
return startMoshSessionViaHandshake(event, preparedOptions, { bareClient, sshExe });
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,17 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
|
||||
|
||||
const req = new AgentInboundRequest(msgType, ctx);
|
||||
this[SYM_REQS].push(req);
|
||||
@@ -781,7 +788,11 @@
|
||||
@@ -732,6 +739,9 @@
|
||||
default: {
|
||||
const req = new AgentInboundRequest(msgType);
|
||||
this[SYM_REQS].push(req);
|
||||
+ // Unknown extensions can carry data. Consume their entire frame
|
||||
+ // before reading the next request on this connection.
|
||||
+ p = 4 + this[SYM_MSGLEN];
|
||||
this.failureReply(req);
|
||||
}
|
||||
}
|
||||
@@ -781,7 +791,11 @@
|
||||
if (pubKey instanceof Error)
|
||||
throw new Error('Invalid public key argument');
|
||||
|
||||
@@ -59,7 +69,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
|
||||
switch (options.hash) {
|
||||
case 'sha256':
|
||||
flags = SSH_AGENT_RSA_SHA2_256;
|
||||
@@ -992,19 +1003,24 @@
|
||||
@@ -992,19 +1006,24 @@
|
||||
let p = 0;
|
||||
const sigFormat = req.getContext();
|
||||
const sigFormatLen = Buffer.byteLength(sigFormat);
|
||||
|
||||
@@ -8,7 +8,8 @@ const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { execFileSync, spawn } = require("node:child_process");
|
||||
const { 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");
|
||||
@@ -114,6 +115,8 @@ test("software FIDO authenticator authenticates against real OpenSSH", {
|
||||
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,
|
||||
@@ -169,6 +172,39 @@ test("software FIDO authenticator authenticates against real OpenSSH", {
|
||||
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?.();
|
||||
@@ -180,7 +216,7 @@ test("software FIDO authenticator authenticates against real OpenSSH", {
|
||||
fs.writeFileSync(authorizedKeys,
|
||||
`cert-authority,verify-required,principals="${username}" ${fs.readFileSync(path.join(dir, "host.pub"), "utf8")}`);
|
||||
const certAgent = await prepareSystemSshAgentForAuth({
|
||||
...options, certificate,
|
||||
...options, certificate, nativeFidoAgent: true,
|
||||
...(type === "ECDSA-SK" ? { privateKey: generated.privateKey, identityFilePaths: [] } : {}),
|
||||
}, "[FidoCertificateSimulation]");
|
||||
const certClient = new Client();
|
||||
@@ -188,6 +224,13 @@ test("software FIDO authenticator authenticates against real OpenSSH", {
|
||||
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?.();
|
||||
|
||||
@@ -11,12 +11,14 @@ 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));
|
||||
@@ -34,6 +36,11 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
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 = [];
|
||||
@@ -90,15 +97,18 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
const session = accept();
|
||||
session.on("exec", acceptExec => {
|
||||
const stream = acceptExec();
|
||||
stream.end("windows-fido-ok");
|
||||
stream.write("windows-fido-ok");
|
||||
stream.exit(0);
|
||||
stream.end();
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
async function connect(generated, password) {
|
||||
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");
|
||||
@@ -113,7 +123,8 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
});
|
||||
client.once("close", () => resolve({ error: new Error("Connection closed") }));
|
||||
});
|
||||
client.connect({ host: "127.0.0.1", port: server.address().port, username: "netcatty", agent,
|
||||
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?.(); } };
|
||||
}
|
||||
@@ -122,6 +133,14 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
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");
|
||||
@@ -156,7 +175,7 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
const before = seen.length;
|
||||
fs.rmSync(`${control}.waiting`, { force: true });
|
||||
fs.writeFileSync(control, "waiting");
|
||||
const connection = await connect(generated);
|
||||
const connection = await connect(generated, undefined, true);
|
||||
let settled = false;
|
||||
connection.outcome.then(() => { settled = true; });
|
||||
try {
|
||||
@@ -176,6 +195,33 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
} 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;
|
||||
@@ -207,13 +253,15 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
} finally { connection.close(); responsePin = "123456"; }
|
||||
});
|
||||
}
|
||||
assert.equal(acceptedPublicKeys, 2, "server must verify both actual SK signatures");
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user