Compare commits

...
23 changed files with 956 additions and 275 deletions

No files matched your search

+1 -1
View File
@@ -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
+17 -1
View File
@@ -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
+15 -8
View File
@@ -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();
+57 -18
View File
@@ -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,
+1 -1
View File
@@ -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 {
+33
View File
@@ -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(); }
});
+15 -1
View File
@@ -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", () => {
+115
View File
@@ -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 };
+179
View File
@@ -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));
}
});
+3 -1
View File
@@ -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,
});
+2
View File
@@ -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
View File
@@ -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
+80 -32
View File
@@ -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,
+39 -6
View File
@@ -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 });
}
+12 -2
View File
@@ -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);
+45 -2
View File
@@ -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?.();
+53 -5
View File
@@ -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;
}