Files
Netcatty/scripts/fido-auth.docker.test.cjs
T
陈大猫andnetcatty-bot 48f1a1cecf feat(ssh): restore FIDO2 security key support (#2823)
* feat(ssh): restore FIDO2 security key support (#2308)

Consolidate the restored feature and its review fixes before rebasing onto current main. Original PR head: 5d0a0c6c9a550364d3ec5c2331395cedfc9ea7ae.

* fix(ssh): verify and repair FIDO authentication with simulated hardware

Preserve complete OpenSSH SK signature payloads, classify combined PIN/presence prompts correctly, expand IdentityFile tokens, retain certificate selectors, and route shared-agent signing prompts to their originating window. Dismiss prompts when notification helpers exit. Add a software SK provider and opt-in real OpenSSH integration coverage for both algorithms, certificates, SFTP, forwarding, and concurrent windows.

* refactor(ui): refine security key forms and authentication motion

Use shared Select and Switch controls, concise localized copy and compact token-based dialogs. Add scoped enter/exit, field transition and presence animations with reduced-motion support. Preserve raw prompt details and visible PIN errors.

* fix(ssh): align security key prompts with hardware presence and verify Docker logins

* fix(ui): keep security key dialog above its remounted overlay

* fix: address Codex review on PR #2823

* feat(ssh): guide security key recovery and explicit authentication fallback

* test(ssh): exercise security key authentication on native Windows

* test(ssh): verify Windows fixture signatures and explicit fallback

* test(ssh): configure Windows OpenSSH toolchain and document coverage

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-10-17 21:53:44 +08:00

251 lines
14 KiB
JavaScript

"use strict";
// An independent Docker sshd verifies signatures made through the production
// Netcatty -> ssh-agent -> ssh-sk-helper -> software authenticator path.
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const { execFileSync, spawnSync } = require("node:child_process");
const { Client } = require("ssh2");
const { generateFidoSshKeyPair } = require("../electron/bridges/fidoSshKeygen.cjs");
const { enhanceAuthOptionsForFido, prepareSystemSshAgentForAuth } = require("../electron/bridges/sshAuthHelper.cjs");
const { shutdownFidoAgentSubsystem } = require("../electron/bridges/fidoAgentManager.cjs");
const prompts = require("../electron/bridges/fidoPromptHandler.cjs");
const askpass = require("../electron/bridges/fidoAskpass.cjs");
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
const { attachFidoAgentRelease } = require("../electron/bridges/attachFidoAgentRelease.cjs");
async function until(check, description) {
const deadline = Date.now() + 5000;
while (!check()) {
if (Date.now() > deadline) throw new Error(`Timed out: ${description}`);
await new Promise(resolve => setTimeout(resolve, 10));
}
}
function exec(client, command) {
return new Promise((resolve, reject) => client.exec(command, (error, channel) => {
if (error) return reject(error);
let output = "";
channel.on("data", chunk => { output += chunk; });
channel.on("error", reject);
channel.on("close", code => code === 0 ? resolve(output) : reject(new Error(`exec exit ${code}`)));
}));
}
test("Docker OpenSSH accepts only completed FIDO PIN/presence authentication", {
skip: process.env.NETCATTY_FIDO_DOCKER !== "1", timeout: 180000,
}, async t => {
const dir = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fd-"));
const fixtures = path.join(dir, "server");
fs.mkdirSync(fixtures, { mode: 0o755 });
const authorized = path.join(fixtures, "authorized_keys");
fs.writeFileSync(authorized, "", { mode: 0o644 });
const control = path.join(dir, "control");
const provider = path.join(dir, "provider.so");
const wrapper = path.join(dir, "ssh-agent");
const name = `netcatty-fido-${randomUUID().slice(0, 8)}`;
const envKeys = ["SSH_SK_PROVIDER", "NETCATTY_SSH_AGENT_PATH", "NETCATTY_FIDO_SIM_CONTROL"];
const oldEnv = Object.fromEntries(envKeys.map(key => [key, process.env[key]]));
const docker = (...args) => {
const result = spawnSync("docker", args, { encoding: "utf8", timeout: 120000 });
if (result.error) throw result.error;
if (result.status !== 0) throw new Error(`docker ${args[0]}: ${result.stderr}`);
// docker logs preserves the container's stdout/stderr streams.
return result.stdout + (args[0] === "logs" ? result.stderr : "");
};
let started = false;
let answer = "123456";
let cancelPin = false;
let retryMissing = false;
let usePassword = false;
const seen = [];
const dismissed = new Set();
const sender = {
id: 2823, isDestroyed: () => false,
send(channel, request) {
if (channel === "netcatty:fido-prompt-cancelled") dismissed.add(request.requestId);
if (channel !== "netcatty:fido-prompt-request") return;
seen.push(request);
// Device notifications deliberately get NO renderer response.
if (request.notificationOnly) return;
if (request.kind === "recovery") {
if (usePassword) queueMicrotask(() => prompts.handleResponse({ sender }, {
requestId: request.requestId, response: "fallback", cancelled: false,
}));
else if (retryMissing && request.reason === "device-unavailable") {
fs.writeFileSync(control, "touch");
queueMicrotask(() => prompts.handleResponse({ sender }, {
requestId: request.requestId, response: "retry", cancelled: false,
}));
} else queueMicrotask(() => prompts.handleResponse({ sender }, {
requestId: request.requestId, cancelled: true,
}));
return;
}
queueMicrotask(() => prompts.handleResponse({ sender }, {
requestId: request.requestId, response: answer, cancelled: cancelPin,
}));
},
};
try {
docker("info", "--format", "{{.ServerVersion}}");
docker("build", "--quiet", "-t", "netcatty-fido-test", path.join(__dirname, "fixtures/fido/docker"));
docker("run", "--rm", "-d", "--name", name, "--publish", "127.0.0.1::2222",
"--mount", `type=bind,source=${fixtures},target=/fixtures,readonly`, "netcatty-fido-test",
"sh", "-c", "ssh-keygen -A && exec /usr/sbin/sshd -D -e -o PasswordAuthentication=yes");
started = true;
const port = Number(JSON.parse(docker("inspect", name))[0].NetworkSettings.Ports["2222/tcp"][0].HostPort);
await until(() => docker("logs", name).includes("Server listening"), "Docker sshd listening");
const password = "Netcatty-disposable-test-password";
execFileSync("docker", ["exec", "-i", name, "chpasswd"], { input: `netcatty:${password}\n` });
execFileSync("cc", ["-shared", "-fPIC", "-DHAVE_STDINT_H",
path.join(__dirname, "fixtures/fido/provider.c"), "-lcrypto", "-o", provider]);
fs.writeFileSync(wrapper, `#!/bin/sh\nexec /usr/bin/ssh-agent -P '${provider}' "$@"\n`, { mode: 0o700 });
Object.assign(process.env, {
SSH_SK_PROVIDER: provider, NETCATTY_SSH_AGENT_PATH: wrapper, NETCATTY_FIDO_SIM_CONTROL: control,
});
askpass.setHelperTimeoutForTests(1500);
for (const type of ["ED25519-SK", "ECDSA-SK"]) {
for (const verifyRequired of [false, true]) {
const label = `${type} / ${verifyRequired ? "PIN + touch" : "touch only"}`;
const generated = await generateFidoSshKeyPair({ type, verifyRequired, resolveWebContents: () => sender });
assert.equal(generated.success, true, generated.error);
fs.writeFileSync(authorized, `${verifyRequired ? "verify-required " : ""}${generated.publicKey}\n`);
const options = await enhanceAuthOptionsForFido({
hostname: "127.0.0.1", port, username: "netcatty", privateKey: generated.privateKey,
keyLabel: "Integration security key",
useSshAgent: false, identitiesOnly: true,
}, sender);
const agent = await prepareSystemSshAgentForAuth(options, "[DockerFido]");
assert.ok(agent);
async function attempt(action, { shouldPass = false, touchLater = false, unplugLater = false, reason } = {}) {
fs.rmSync(`${control}.waiting`, { force: true });
fs.writeFileSync(control, action);
const before = seen.length;
const client = new Client();
// ssh2 may emit both an agent error and the final auth failure.
client.on("error", () => {});
let ready = false;
client.once("ready", () => { ready = true; });
// Attach both outcomes before connecting: denied authentication is expected.
const outcome = new Promise(resolve => {
client.once("ready", () => resolve({ ready: true }));
client.once("error", error => resolve({ error }));
});
try {
client.connect({ host: "127.0.0.1", port, username: "netcatty", agent, readyTimeout: 12000 });
if (touchLater || unplugLater) {
await until(() => fs.existsSync(`${control}.waiting`), "authenticator waiting for touch");
// A valid PIN or a notification is not sufficient to log in.
await new Promise(resolve => setTimeout(resolve, 200));
assert.equal(ready, false, "must wait for physical presence");
await until(() => seen.slice(before).some(p => p.notificationOnly), "touch waiting notification");
fs.writeFileSync(control, unplugLater ? "unplugged" : "touch");
}
const result = await outcome;
if (shouldPass) {
assert.equal(result.ready, true, result.error?.message);
assert.equal(await exec(client, "printf docker-fido-ok"), "docker-fido-ok");
const sftp = await new Promise((resolve, reject) => client.sftp((error, value) => error ? reject(error) : resolve(value)));
const stat = await new Promise((resolve, reject) => sftp.stat("/fixtures/authorized_keys", (error, value) => error ? reject(error) : resolve(value)));
assert.ok(stat.size > 0);
sftp.end();
} else {
assert.equal(ready, false, "invalid authentication must not become ready");
assert.match(result.error?.message || "", /authentication methods failed|agent|sign|cancelled/i);
}
const current = seen.slice(before);
assert.equal(current.filter(p => p.kind === "pin").length,
verifyRequired && action !== "unsupported" && (action !== "unplugged" || retryMissing) ? 1 : 0);
const recovery = current.filter(p => p.kind === "recovery");
assert.deepEqual(recovery.map(p => p.reason), reason ? [reason] : []);
assert.ok(recovery.every(p => p.keyName === "Integration security key" && !p.allowPasswordFallback));
const notifications = current.filter(p => p.kind === "touch");
assert.ok(notifications.every(p => p.notificationOnly === true));
await until(() => notifications.every(p => dismissed.has(p.requestId)), "automatic notification dismissal");
} finally {
client.end();
await until(() => prompts.getRequests().size === 0, "no stale prompt");
}
}
try {
await t.test(`${label}: gated login, automatic continuation, exec and SFTP`, () =>
attempt("wait", { shouldPass: true, touchLater: true }));
await t.test(`${label}: no touch times out and stops the helper`, () => attempt("wait", { reason: "timeout" }));
await t.test(`${label}: missing token requests the matching device`, () => attempt("unplugged", { reason: "device-unavailable" }));
await t.test(`${label}: unsupported hardware is not misreported as a missing device`, () => attempt("unsupported", { reason: "failed" }));
await t.test(`${label}: removal during confirmation is distinct from timeout`, () => attempt("wait", { unplugLater: true, reason: "device-unavailable" }));
await t.test(`${label}: insert and explicitly retry completes the pending login`, async () => {
retryMissing = true;
try { await attempt("unplugged", { shouldPass: true, reason: "device-unavailable" }); }
finally { retryMissing = false; }
});
await t.test(`${label}: server rejects signature without presence flag`, () => attempt("no-up"));
if (verifyRequired) {
await t.test(`${label}: wrong PIN is denied`, async () => {
answer = "000000";
try { await attempt("touch", { reason: "pin-failed" }); } finally { answer = "123456"; }
});
await t.test(`${label}: cancelled PIN is denied`, async () => {
cancelPin = true;
try { await attempt("touch"); } finally { cancelPin = false; }
});
await t.test(`${label}: server rejects signature without verification flag`, () => attempt("no-uv"));
}
await t.test(`${label}: a fresh login still succeeds after failures`, () => attempt("touch", { shouldPass: true }));
} finally { agent._releaseNetcattyFidoAgent?.(); }
}
}
for (const fallback of [false, true]) {
await t.test(fallback ? "explicit password choice permits configured fallback" : "cancel never falls back to the configured password", async () => {
usePassword = fallback;
const generated = await generateFidoSshKeyPair({ type: "ED25519-SK", verifyRequired: true, resolveWebContents: () => sender });
assert.equal(generated.success, true, generated.error);
fs.writeFileSync(authorized, `verify-required ${generated.publicKey}\n`);
const options = await enhanceAuthOptionsForFido({ hostname: "127.0.0.1", port, username: "netcatty",
privateKey: generated.privateKey, password, keyLabel: "Fallback test key", identitiesOnly: true }, sender);
const agent = await prepareSystemSshAgentForAuth(options, "[DockerFidoFallback]");
const client = new Client();
client.on("error", () => {});
attachFidoAgentRelease(client, agent);
const before = seen.length;
const outcome = new Promise(resolve => {
client.once("ready", () => resolve(true));
client.once("error", error => { if (error.code === "ERR_FIDO_CANCELLED") resolve(false); });
});
fs.writeFileSync(control, "unplugged");
try {
client.connect({ host: "127.0.0.1", port, username: "netcatty", agent, password,
authHandler: ["agent", "password"], readyTimeout: 10000 });
assert.equal(await outcome, fallback);
const recovery = seen.slice(before).find(p => p.kind === "recovery");
assert.equal(recovery.reason, "device-unavailable");
assert.equal(recovery.allowPasswordFallback, true);
if (fallback) assert.equal(await exec(client, "printf explicit-password-ok"), "explicit-password-ok");
} finally { client.end(); agent._releaseNetcattyFidoAgent?.(); usePassword = false; }
});
}
const logs = docker("logs", name);
assert.match(logs, /Accepted publickey/);
// These messages prove server-side UP/UV enforcement, independently of UI.
assert.match(logs, /user presence.*requirement not met/);
assert.match(logs, /user verification.*requirement not met/);
} catch (error) {
if (started) error.message += `\nDocker sshd log:\n${docker("logs", name)}`;
throw error;
} finally {
shutdownFidoAgentSubsystem();
askpass.shutdownFidoAskpass();
askpass.setHelperTimeoutForTests(null);
if (started) docker("rm", "-f", name);
for (const [key, value] of Object.entries(oldEnv)) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
fs.rmSync(dir, { recursive: true, force: true });
}
});