[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
560
electron/bridges/sshMfaModels.live.test.cjs
Normal file
560
electron/bridges/sshMfaModels.live.test.cjs
Normal file
@@ -0,0 +1,560 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user