Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
561 lines
19 KiB
JavaScript
561 lines
19 KiB
JavaScript
/**
|
|
* Live + mock matrix for SSH MFA models related to #2150 / #2217.
|
|
*
|
|
* Always runs:
|
|
* - Mock models A / B / C / password-only against Netcatty-style auth ordering
|
|
* (credentials below are synthetic fixture strings, not real lab secrets)
|
|
*
|
|
* Optionally runs against a lab host when env is fully configured:
|
|
* SSH_MFA_LIVE=1
|
|
* SSH_MFA_HOST=...
|
|
* SSH_MFA_PORT=22 (optional)
|
|
* SSH_MFA_EDRPW_PASSWORD=...
|
|
* SSH_MFA_EDRTEST_PASSWORD=...
|
|
* SSH_MFA_EDRTEST_TOTP_SECRET=...
|
|
* SSH_MFA_EDRMIX_PASSWORD=...
|
|
* SSH_MFA_EDRMIX_TOTP_SECRET=...
|
|
* SSH_MFA_EDRSEC_PASSWORD=...
|
|
* SSH_MFA_EDRSEC_SECONDARY=...
|
|
*
|
|
* Usage:
|
|
* node --test electron/bridges/sshMfaModels.live.test.cjs
|
|
* SSH_MFA_LIVE=1 SSH_MFA_HOST=... ... node --test electron/bridges/sshMfaModels.live.test.cjs
|
|
*/
|
|
|
|
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const { EventEmitter } = require("node:events");
|
|
const crypto = require("node:crypto");
|
|
const Module = require("node:module");
|
|
const path = require("node:path");
|
|
|
|
const LIVE_FLAG = process.env.SSH_MFA_LIVE === "1" || process.env.SSH_MFA_LIVE === "true";
|
|
const LIVE_HOST = process.env.SSH_MFA_HOST || "";
|
|
const LIVE_PORT = Number(process.env.SSH_MFA_PORT || 22);
|
|
|
|
function env(name) {
|
|
const value = process.env[name];
|
|
return typeof value === "string" && value.length > 0 ? value : "";
|
|
}
|
|
|
|
// Live credentials are NEVER hardcoded — supply via environment for private lab runs.
|
|
const LAB = {
|
|
edrpw: {
|
|
username: env("SSH_MFA_EDRPW_USER") || "edrpw",
|
|
password: env("SSH_MFA_EDRPW_PASSWORD"),
|
|
model: "password-only",
|
|
},
|
|
edrtest: {
|
|
username: env("SSH_MFA_EDRTEST_USER") || "edrtest",
|
|
password: env("SSH_MFA_EDRTEST_PASSWORD"),
|
|
totpSecret: env("SSH_MFA_EDRTEST_TOTP_SECRET"),
|
|
model: "B-dual-ki-totp",
|
|
},
|
|
edrmix: {
|
|
username: env("SSH_MFA_EDRMIX_USER") || "edrmix",
|
|
password: env("SSH_MFA_EDRMIX_PASSWORD"),
|
|
totpSecret: env("SSH_MFA_EDRMIX_TOTP_SECRET"),
|
|
model: "B-dual-ki-totp-alt",
|
|
},
|
|
edrsec: {
|
|
username: env("SSH_MFA_EDRSEC_USER") || "edrsec",
|
|
password: env("SSH_MFA_EDRSEC_PASSWORD"),
|
|
secondaryPassword: env("SSH_MFA_EDRSEC_SECONDARY"),
|
|
model: "B-dual-ki-secondary-password",
|
|
},
|
|
};
|
|
|
|
function hasLiveCreds(entry) {
|
|
if (!entry?.password) return false;
|
|
if ("totpSecret" in entry && entry.model.includes("totp") && !entry.totpSecret) return false;
|
|
if ("secondaryPassword" in entry && entry.model.includes("secondary") && !entry.secondaryPassword) return false;
|
|
return true;
|
|
}
|
|
|
|
const LIVE = LIVE_FLAG && !!LIVE_HOST;
|
|
|
|
function base32Decode(secret) {
|
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
const cleaned = String(secret).toUpperCase().replace(/=+$/g, "").replace(/\s+/g, "");
|
|
let bits = "";
|
|
for (const ch of cleaned) {
|
|
const val = alphabet.indexOf(ch);
|
|
if (val < 0) continue;
|
|
bits += val.toString(2).padStart(5, "0");
|
|
}
|
|
const bytes = [];
|
|
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
|
bytes.push(parseInt(bits.slice(i, i + 8), 2));
|
|
}
|
|
return Buffer.from(bytes);
|
|
}
|
|
|
|
function totp(secret, atMs = Date.now(), stepSec = 30) {
|
|
const key = base32Decode(secret);
|
|
const counter = Math.floor(atMs / 1000 / stepSec);
|
|
const msg = Buffer.alloc(8);
|
|
msg.writeBigUInt64BE(BigInt(counter));
|
|
const h = crypto.createHmac("sha1", key).update(msg).digest();
|
|
const offset = h[h.length - 1] & 0xf;
|
|
const code = (h.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
|
|
return String(code).padStart(6, "0");
|
|
}
|
|
|
|
function isPasswordLikePrompt(prompt) {
|
|
const p = String(prompt || "").toLowerCase();
|
|
return p.includes("password") && !p.includes("secondary") && !p.includes("verification") && !p.includes("code");
|
|
}
|
|
|
|
function isSecondaryLikePrompt(prompt) {
|
|
const p = String(prompt || "").toLowerCase();
|
|
return (
|
|
p.includes("secondary authentication")
|
|
|| p.includes("verification code")
|
|
|| p.includes("authentication code")
|
|
|| (p.includes("code") && !p.includes("password"))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Connect with Netcatty-like preference: password before keyboard-interactive.
|
|
* Records offered methods and KI rounds for assertions.
|
|
*/
|
|
function connectWithNetcattyOrder(opts) {
|
|
// Lazy require so mock tests can patch first if needed
|
|
const { Client } = require("ssh2");
|
|
const {
|
|
host,
|
|
port = 22,
|
|
username,
|
|
password,
|
|
totpSecret,
|
|
secondaryPassword,
|
|
readyTimeout = 15000,
|
|
} = opts;
|
|
|
|
const offered = [];
|
|
const kiRounds = [];
|
|
let passwordMethodTried = false;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const conn = new Client();
|
|
let settled = false;
|
|
const done = (err, result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try { conn.end(); } catch { /* ignore */ }
|
|
if (err) reject(err);
|
|
else resolve(result);
|
|
};
|
|
|
|
conn.on("ready", () => {
|
|
done(null, {
|
|
offered,
|
|
kiRounds,
|
|
passwordMethodTried,
|
|
ok: true,
|
|
});
|
|
});
|
|
conn.on("error", (err) => done(err));
|
|
|
|
const order = ["none", "password", "keyboard-interactive"];
|
|
let attempted = new Set();
|
|
let succeeded = new Set();
|
|
let failed = new Set();
|
|
let lastOffered = null;
|
|
let hadPartial = false;
|
|
|
|
conn.connect({
|
|
host,
|
|
port,
|
|
username,
|
|
password,
|
|
tryKeyboard: true,
|
|
readyTimeout,
|
|
authHandler: (methodsLeft, partialSuccess, callback) => {
|
|
if (partialSuccess) {
|
|
hadPartial = true;
|
|
if (lastOffered) succeeded.add(lastOffered);
|
|
attempted = new Set([...failed, ...succeeded]);
|
|
if (Array.isArray(methodsLeft) && methodsLeft.includes("keyboard-interactive")) {
|
|
attempted.delete("keyboard-interactive");
|
|
}
|
|
} else if (lastOffered && methodsLeft != null) {
|
|
failed.add(lastOffered);
|
|
}
|
|
|
|
const available = Array.isArray(methodsLeft) && methodsLeft.length
|
|
? methodsLeft
|
|
: null;
|
|
|
|
// After partial success prefer KI (matches current Netcatty strategy)
|
|
if (
|
|
hadPartial
|
|
&& (!available || available.includes("keyboard-interactive"))
|
|
&& !attempted.has("keyboard-interactive")
|
|
) {
|
|
attempted.add("keyboard-interactive");
|
|
lastOffered = "keyboard-interactive";
|
|
offered.push("keyboard-interactive");
|
|
return callback("keyboard-interactive");
|
|
}
|
|
|
|
for (const method of order) {
|
|
if (attempted.has(method)) continue;
|
|
if (available && !available.includes(method) && method !== "none") continue;
|
|
attempted.add(method);
|
|
lastOffered = method;
|
|
offered.push(method);
|
|
if (method === "password") passwordMethodTried = true;
|
|
return callback(method === "none" ? "none" : method);
|
|
}
|
|
return callback(false);
|
|
},
|
|
});
|
|
|
|
conn.on("keyboard-interactive", (name, instructions, lang, prompts, finish) => {
|
|
const round = {
|
|
name: name || "",
|
|
instructions: instructions || "",
|
|
prompts: (prompts || []).map((p) => ({
|
|
prompt: p.prompt,
|
|
echo: p.echo,
|
|
})),
|
|
};
|
|
kiRounds.push(round);
|
|
|
|
const responses = (prompts || []).map((p) => {
|
|
const text = p.prompt || "";
|
|
if (isSecondaryLikePrompt(text)) {
|
|
if (totpSecret) return totp(totpSecret);
|
|
if (secondaryPassword) return secondaryPassword;
|
|
return "";
|
|
}
|
|
if (isPasswordLikePrompt(text) || /password/i.test(text)) {
|
|
return password || "";
|
|
}
|
|
// Default: TOTP if looks like code, else password
|
|
if (/code/i.test(text) && totpSecret) return totp(totpSecret);
|
|
return password || "";
|
|
});
|
|
finish(responses);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock models (always on) — Model A / B / C / password-only
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createMockServer(scenario) {
|
|
class MockClient extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
MockClient.instances.push(this);
|
|
this.authMethodsOffered = [];
|
|
this.kiRounds = [];
|
|
this.passwordMethodTries = 0;
|
|
}
|
|
|
|
connect(opts) {
|
|
this.connectOpts = opts;
|
|
setImmediate(() => this._run(opts));
|
|
}
|
|
|
|
_run(opts) {
|
|
const offer = (methodsLeft, partialSuccess) => {
|
|
let offered;
|
|
opts.authHandler(methodsLeft, partialSuccess, (method) => {
|
|
offered = method;
|
|
this.authMethodsOffered.push(method && typeof method === "object" ? method.type || method : method);
|
|
});
|
|
return offered;
|
|
};
|
|
|
|
this.emit("connect");
|
|
this.emit("handshake");
|
|
offer(null, null); // none
|
|
|
|
if (scenario === "password-only") {
|
|
const m = offer(["password", "keyboard-interactive"], false);
|
|
if (m !== "password") {
|
|
return this.emit("error", Object.assign(new Error("expected password first"), { level: "client-authentication" }));
|
|
}
|
|
this.passwordMethodTries += 1;
|
|
this.emit("ready");
|
|
return;
|
|
}
|
|
|
|
if (scenario === "A-password-then-ki") {
|
|
// Server advertises both; password partial, then KI secondary
|
|
const m1 = offer(["password", "keyboard-interactive"], false);
|
|
if (m1 !== "password") {
|
|
return this.emit("error", Object.assign(new Error(`expected password, got ${m1}`), { level: "client-authentication" }));
|
|
}
|
|
this.passwordMethodTries += 1;
|
|
const m2 = offer(["password", "keyboard-interactive"], true);
|
|
if (m2 !== "keyboard-interactive") {
|
|
return this.emit("error", Object.assign(new Error(`expected KI after partial, got ${m2}`), { level: "client-authentication" }));
|
|
}
|
|
this.emit(
|
|
"keyboard-interactive",
|
|
"Keyboard-interactive authentication prompts from server",
|
|
"为保障主机安全,请输入二次认证密码",
|
|
"",
|
|
[{ prompt: "Secondary Authentication Password:", echo: false }],
|
|
(responses) => {
|
|
this.kiRounds.push(responses);
|
|
if (responses[0] === "secondary-secret") this.emit("ready");
|
|
else this.emit("error", Object.assign(new Error("bad secondary"), { level: "client-authentication" }));
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (scenario === "B-dual-ki") {
|
|
// Server only KI; two rounds
|
|
const m1 = offer(["keyboard-interactive"], false);
|
|
if (m1 !== "keyboard-interactive") {
|
|
return this.emit("error", Object.assign(new Error(`expected KI, got ${m1}`), { level: "client-authentication" }));
|
|
}
|
|
this.emit(
|
|
"keyboard-interactive",
|
|
"",
|
|
"",
|
|
"",
|
|
[{ prompt: "Password:", echo: false }],
|
|
(responses) => {
|
|
this.kiRounds.push(responses);
|
|
const m2 = offer(["keyboard-interactive"], true);
|
|
if (m2 !== "keyboard-interactive") {
|
|
return this.emit("error", Object.assign(new Error(`expected second KI, got ${m2}`), { level: "client-authentication" }));
|
|
}
|
|
this.emit(
|
|
"keyboard-interactive",
|
|
"",
|
|
"",
|
|
"",
|
|
[{ prompt: "Verification code:", echo: false }],
|
|
(responses2) => {
|
|
this.kiRounds.push(responses2);
|
|
this.emit("ready");
|
|
},
|
|
);
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (scenario === "C-password-full-skips-mfa") {
|
|
// Pathological: both advertised, password fully succeeds, no KI
|
|
const m1 = offer(["password", "keyboard-interactive"], false);
|
|
if (m1 !== "password") {
|
|
return this.emit("error", Object.assign(new Error(`expected password, got ${m1}`), { level: "client-authentication" }));
|
|
}
|
|
this.passwordMethodTries += 1;
|
|
// Server never sends KI — documents gap for model C
|
|
this.emit("ready");
|
|
return;
|
|
}
|
|
|
|
this.emit("error", new Error(`unknown scenario ${scenario}`));
|
|
}
|
|
|
|
end() {}
|
|
destroy() {}
|
|
}
|
|
MockClient.instances = [];
|
|
return MockClient;
|
|
}
|
|
|
|
function runMockScenario(scenario, { password = "login-password", secondary = "secondary-secret", totpCode = "123456" } = {}) {
|
|
const MockClient = createMockServer(scenario);
|
|
const originalLoad = Module._load;
|
|
const ssh2PathHints = ["ssh2", path.join("node_modules", "ssh2")];
|
|
Module._load = function patched(request, parent, isMain) {
|
|
if (request === "ssh2" || ssh2PathHints.some((h) => String(request).includes("ssh2") && request.endsWith("ssh2"))) {
|
|
// only replace exact ssh2 package
|
|
}
|
|
if (request === "ssh2") {
|
|
return { Client: MockClient, utils: { parseKey: () => new Error("n/a") } };
|
|
}
|
|
return originalLoad.call(this, request, parent, isMain);
|
|
};
|
|
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
// inline connect without require cache issues
|
|
const conn = new MockClient();
|
|
const offered = [];
|
|
const kiRounds = [];
|
|
let passwordMethodTried = false;
|
|
let hadPartial = false;
|
|
const order = ["none", "password", "keyboard-interactive"];
|
|
let attempted = new Set();
|
|
let succeeded = new Set();
|
|
let failed = new Set();
|
|
let lastOffered = null;
|
|
|
|
conn.on("ready", () => {
|
|
Module._load = originalLoad;
|
|
resolve({
|
|
offered: conn.authMethodsOffered,
|
|
kiRounds: conn.kiRounds,
|
|
passwordMethodTries: conn.passwordMethodTries,
|
|
passwordMethodTried,
|
|
ok: true,
|
|
});
|
|
});
|
|
conn.on("error", (err) => {
|
|
Module._load = originalLoad;
|
|
reject(err);
|
|
});
|
|
conn.on("keyboard-interactive", (name, instructions, lang, prompts, finish) => {
|
|
const responses = (prompts || []).map((p) => {
|
|
const text = p.prompt || "";
|
|
if (isSecondaryLikePrompt(text)) return secondary || totpCode;
|
|
return password;
|
|
});
|
|
finish(responses);
|
|
});
|
|
|
|
conn.connect({
|
|
username: "alice",
|
|
password,
|
|
tryKeyboard: true,
|
|
authHandler: (methodsLeft, partialSuccess, callback) => {
|
|
if (partialSuccess) {
|
|
hadPartial = true;
|
|
if (lastOffered) succeeded.add(lastOffered);
|
|
attempted = new Set([...failed, ...succeeded]);
|
|
if (Array.isArray(methodsLeft) && methodsLeft.includes("keyboard-interactive")) {
|
|
attempted.delete("keyboard-interactive");
|
|
}
|
|
} else if (lastOffered && methodsLeft != null) {
|
|
failed.add(lastOffered);
|
|
}
|
|
const available = Array.isArray(methodsLeft) && methodsLeft.length ? methodsLeft : null;
|
|
if (
|
|
hadPartial
|
|
&& (!available || available.includes("keyboard-interactive"))
|
|
&& !attempted.has("keyboard-interactive")
|
|
) {
|
|
attempted.add("keyboard-interactive");
|
|
lastOffered = "keyboard-interactive";
|
|
return callback("keyboard-interactive");
|
|
}
|
|
for (const method of order) {
|
|
if (attempted.has(method)) continue;
|
|
if (available && method !== "none" && !available.includes(method)) continue;
|
|
attempted.add(method);
|
|
lastOffered = method;
|
|
if (method === "password") passwordMethodTried = true;
|
|
return callback(method);
|
|
}
|
|
return callback(false);
|
|
},
|
|
});
|
|
} catch (err) {
|
|
Module._load = originalLoad;
|
|
reject(err);
|
|
}
|
|
});
|
|
}
|
|
|
|
test("mock model password-only: tries password method first and succeeds", async () => {
|
|
const result = await runMockScenario("password-only");
|
|
assert.equal(result.passwordMethodTries, 1);
|
|
assert.deepEqual(result.offered, ["none", "password"]);
|
|
assert.equal(result.kiRounds.length, 0);
|
|
});
|
|
|
|
test("mock model A: password first, partial, then KI secondary (EDR-like)", async () => {
|
|
const result = await runMockScenario("A-password-then-ki");
|
|
assert.equal(result.passwordMethodTries, 1);
|
|
assert.deepEqual(result.offered, ["none", "password", "keyboard-interactive"]);
|
|
assert.equal(result.kiRounds.length, 1);
|
|
assert.deepEqual(result.kiRounds[0], ["secondary-secret"]);
|
|
});
|
|
|
|
test("mock model B: dual keyboard-interactive (password then verification code)", async () => {
|
|
const result = await runMockScenario("B-dual-ki", { password: "login-password", secondary: "123456" });
|
|
assert.equal(result.passwordMethodTries, 0);
|
|
assert.ok(result.offered.includes("keyboard-interactive"));
|
|
assert.equal(result.kiRounds.length, 2);
|
|
assert.deepEqual(result.kiRounds[0], ["login-password"]);
|
|
assert.deepEqual(result.kiRounds[1], ["123456"]);
|
|
});
|
|
|
|
test("mock model C: password full success skips KI (documents remaining gap)", async () => {
|
|
const result = await runMockScenario("C-password-full-skips-mfa");
|
|
assert.equal(result.passwordMethodTries, 1);
|
|
assert.deepEqual(result.offered, ["none", "password"]);
|
|
assert.equal(result.kiRounds.length, 0);
|
|
// This is the pathological server behavior: client cannot force MFA if server
|
|
// accepts password as full authentication.
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Live lab host
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const liveEdrpw = LIVE && hasLiveCreds(LAB.edrpw) ? test : test.skip;
|
|
const liveEdrtest = LIVE && hasLiveCreds(LAB.edrtest) ? test : test.skip;
|
|
const liveEdrmix = LIVE && hasLiveCreds(LAB.edrmix) ? test : test.skip;
|
|
const liveEdrsec = LIVE && hasLiveCreds(LAB.edrsec) ? test : test.skip;
|
|
|
|
liveEdrpw("live edrpw: password method only", async () => {
|
|
const result = await connectWithNetcattyOrder({
|
|
host: LIVE_HOST,
|
|
port: LIVE_PORT,
|
|
username: LAB.edrpw.username,
|
|
password: LAB.edrpw.password,
|
|
});
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.passwordMethodTried, true);
|
|
assert.ok(result.offered.includes("password"));
|
|
assert.equal(result.kiRounds.length, 0);
|
|
});
|
|
|
|
liveEdrtest("live edrtest: dual KI password + TOTP (model B)", async () => {
|
|
const result = await connectWithNetcattyOrder({
|
|
host: LIVE_HOST,
|
|
port: LIVE_PORT,
|
|
username: LAB.edrtest.username,
|
|
password: LAB.edrtest.password,
|
|
totpSecret: LAB.edrtest.totpSecret,
|
|
});
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.passwordMethodTried, false, "server only allows KI");
|
|
assert.ok(result.kiRounds.length >= 2, `expected >=2 KI rounds, got ${result.kiRounds.length}`);
|
|
const prompts = result.kiRounds.map((r) => r.prompts.map((p) => p.prompt).join("|")).join(" || ");
|
|
assert.match(prompts, /password/i);
|
|
assert.match(prompts, /code/i);
|
|
});
|
|
|
|
liveEdrmix("live edrmix: dual KI password + TOTP alt secret (model B variant)", async () => {
|
|
const result = await connectWithNetcattyOrder({
|
|
host: LIVE_HOST,
|
|
port: LIVE_PORT,
|
|
username: LAB.edrmix.username,
|
|
password: LAB.edrmix.password,
|
|
totpSecret: LAB.edrmix.totpSecret,
|
|
});
|
|
assert.equal(result.ok, true);
|
|
assert.ok(result.kiRounds.length >= 2);
|
|
});
|
|
|
|
liveEdrsec("live edrsec: dual KI with Secondary Authentication Password prompt", async () => {
|
|
const result = await connectWithNetcattyOrder({
|
|
host: LIVE_HOST,
|
|
port: LIVE_PORT,
|
|
username: LAB.edrsec.username,
|
|
password: LAB.edrsec.password,
|
|
secondaryPassword: LAB.edrsec.secondaryPassword,
|
|
});
|
|
assert.equal(result.ok, true);
|
|
assert.ok(result.kiRounds.length >= 2, `expected >=2 KI rounds, got ${JSON.stringify(result.kiRounds, null, 2)}`);
|
|
const allPrompts = result.kiRounds.flatMap((r) => r.prompts.map((p) => p.prompt)).join("\n");
|
|
assert.match(allPrompts, /Secondary Authentication Password/i);
|
|
});
|