"use strict"; const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const { EventEmitter } = require("node:events"); const { SessionManager } = require("../src/main/sessions/sessionManager"); const { PrivilegedExecutor, SUDO_PROMPT } = require("../src/main/sessions/privilegedExec"); const keys = require("../src/main/users/authorizedKeys"); const policy = require("../src/main/users/sshPolicy"); const { UserAdminService, validateComment, validateHome, validateName, validateShell, splitSections } = require("../src/main/users/userAdminService"); // Public test keys; fingerprints were computed independently with ssh-keygen -lf. const KEY_A = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMtHrhcqui+RtemmVnYdQeb4FFPCnLpDGHdbW1x27n3f fixture-a"; const KEY_B = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDxTAuuNqjy7bQQmrG2sNb9sQ3D7YDvUolTWt9PplroB fixture-b"; const KEY_RSA = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCgoqY+n4WTJHhQDDG3/Wq2ikgvEMDAEqf659oJvPxoT1786rRE8b1pA/TO7HdR3huqWVEOFgTtsasnwzzYbtk30CsrjdnfIYDt8g6wQcEirkZ4zlhX7SDLmgytRytIEawCsPruj31gh6YoYKTr8XV2TxZtBlkZCbG47+MYUz7tNw== fixture-rsa"; const FP_A = "SHA256:4wwpzucK1K7t/mhInHzZAspG1n28pLehytTkdkEty+4"; const FP_B = "SHA256:YbW5raLxpO5Bw7+M/3TL7Oe13zRU0AJWZrJJ/UIvGWA"; const FP_RSA = "SHA256:XU/sToLGlIj0r/7pEWX2OmVd/wVtkA2KhY4OOxKg48g"; test("authorized_keys parsing matches ssh-keygen fingerprints and keeps options and comments", () => { const content = `# comment line\r\n${KEY_RSA}\r\ncommand="echo a b",from="10.0.0.0/8" ${KEY_A}\n\ngarbage line here\n`; const entries = keys.parseAuthorizedKeys(content); assert.deepEqual(entries.map((entry) => [entry.line, entry.valid, entry.fingerprint || null]), [[2, true, FP_RSA], [3, true, FP_A], [5, false, null]]); assert.equal(entries[1].options, 'command="echo a b",from="10.0.0.0/8"'); assert.equal(entries[1].comment, "fixture-a"); assert.equal(entries[1].ed25519, true); assert.equal(entries[0].ed25519, false); }); test("adding and removing a key changes exactly one line and preserves all other bytes", () => { const original = Buffer.from(`# keep\r\n${KEY_RSA}\r\nno-pty ${KEY_A}`, "latin1"); const key = keys.normalizePublicKey(KEY_B); assert.equal(key.fingerprint, FP_B); const added = keys.appendKey(original, key); assert.equal(added.toString("latin1"), `${original.toString("latin1")}\n${KEY_B}\n`); assert.throws(() => keys.appendKey(added, keys.normalizePublicKey(KEY_B)), { code: "USERS_KEY_DUPLICATE" }); const entry = keys.parseAuthorizedKeys(added).find((candidate) => candidate.fingerprint === FP_A); const removed = keys.removeKey(added, entry.line, FP_A); assert.equal(removed.toString("latin1"), `# keep\r\n${KEY_RSA}\r\n${KEY_B}\n`); assert.throws(() => keys.removeKey(added, entry.line, FP_B), { code: "USERS_KEY_NOT_FOUND" }); assert.throws(() => keys.removeKey(added, 99, FP_A), { code: "USERS_KEY_NOT_FOUND" }); // UTF-8 comments survive the latin1 byte-preserving round trip. const utf8 = keys.appendKey(Buffer.alloc(0), keys.normalizePublicKey(KEY_A, "Jiří Novák")); assert.equal(utf8.toString("utf8"), `${KEY_A.replace("fixture-a", "Jiří Novák")}\n`); assert.equal(keys.parseAuthorizedKeys(utf8)[0].comment, "Jiří Novák"); }); test("only one plain Ed25519 public key is accepted for upload", () => { assert.throws(() => keys.normalizePublicKey(KEY_RSA), { code: "USERS_KEY_TYPE_UNSUPPORTED" }); assert.throws(() => keys.normalizePublicKey(`${KEY_A}\n${KEY_B}`), { code: "USERS_KEY_INVALID" }); assert.throws(() => keys.normalizePublicKey(`command="id" ${KEY_A}`), { code: "USERS_KEY_INVALID" }); assert.throws(() => keys.normalizePublicKey("-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----"), { code: /USERS_KEY_(PRIVATE|INVALID)/ }); assert.throws(() => keys.normalizePublicKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMtH x"), { code: "USERS_KEY_INVALID" }); assert.throws(() => keys.normalizePublicKey(KEY_A.replace("AAAAC3NzaC1lZDI1NTE5", "AAAAB3NzaC1yc2EAAAAD")), { code: "USERS_KEY_INVALID" }); assert.equal(keys.normalizePublicKey(`${KEY_A}\r\n`).fingerprint, FP_A); }); test("managed sshd policy is generated deterministically and manual edits are detected", () => { const policies = new Map([ ["bob", policy.normalizePolicy({ ipv6: "deny", password: "no" })], ["alice", policy.normalizePolicy({ access: "deny" })], ["carol", policy.normalizePolicy({})] ]); const text = policy.generateManagedConfig(policies); assert.match(text, /^# CH-J Server Manager/); assert.match(text, /\nDenyUsers alice\nDenyUsers bob@::\/0\nMatch User bob\n\tPasswordAuthentication no\n\tKbdInteractiveAuthentication no\nMatch all\n$/); assert.doesNotMatch(text, /carol/, "inherit-only policies are not written"); const parsed = policy.parseManagedConfig(text); assert.equal(parsed.consistent, true); assert.deepEqual([...parsed.policies.keys()], ["alice", "bob"]); assert.equal(policy.parseManagedConfig(text.replace("DenyUsers alice\n", "")).consistent, false); assert.equal(policy.parseManagedConfig(`${text}PermitRootLogin yes\n`).consistent, false); assert.equal(policy.parseManagedConfig("").consistent, true); assert.equal(policy.generateManagedConfig(new Map([["x", policy.normalizePolicy({})]])), ""); assert.throws(() => policy.normalizePolicy({ password: "maybe" }), { code: "USERS_SSH_POLICY_INVALID" }); assert.throws(() => policy.normalizePolicy({ ipv4: "deny\nPermitRootLogin yes" }), { code: "USERS_SSH_POLICY_INVALID" }); }); test("sshd -T output is evaluated per user, address family and authentication method", () => { const dump = policy.parseEffective([ "passwordauthentication yes", "kbdinteractiveauthentication no", "pubkeyauthentication yes", "permitrootlogin prohibit-password", "authenticationmethods any", "denyusers bob@0.0.0.0/0", "denyusers eve", "denyusers mallory@192.168.*", "addressfamily any", "listenaddress 0.0.0.0:22", "listenaddress [::]:22" ].join("\n")); assert.equal(policy.evaluateAccess(dump, { username: "bob", address: "203.0.113.5" }).allowed, false); assert.equal(policy.evaluateAccess(dump, { username: "bob", address: "2001:db8::5" }).allowed, true); assert.equal(policy.evaluateAccess(dump, { username: "eve", address: "2001:db8::5" }).allowed, false); assert.equal(policy.evaluateAccess(dump, { username: "mallory", address: "192.168.1.4" }).allowed, false); assert.equal(policy.evaluateAccess(dump, { username: "mallory", address: "10.1.1.1" }).allowed, true); assert.equal(policy.evaluateAccess(dump, { username: "root", address: "10.1.1.1", method: "password" }).methodAllowed, false); assert.equal(policy.evaluateAccess(dump, { username: "root", address: "10.1.1.1", method: "publickey" }).methodAllowed, true); assert.deepEqual(policy.listenerFamilies(dump), { ipv4: true, ipv6: true }); const restricted = policy.parseEffective("passwordauthentication yes\npubkeyauthentication yes\nauthenticationmethods publickey,password\nallowusers admin\nallowusers deploy@10.0.0.0/8\nallowgroups ops\nallowgroups openssh users\naddressfamily inet"); assert.equal(policy.evaluateAccess(restricted, { username: "deploy", groups: ["ops"], address: "10.2.3.4", method: "publickey" }).methodAllowed, false, "publickey alone does not satisfy publickey,password"); assert.equal(policy.evaluateAccess(restricted, { username: "deploy", groups: ["ops"], address: "10.2.3.4" }).allowed, true); assert.equal(policy.evaluateAccess(restricted, { username: "deploy", groups: ["ops"], address: "172.16.0.1" }).allowed, false); assert.equal(policy.evaluateAccess(restricted, { username: "admin", groups: ["users"], address: "10.2.3.4" }).allowed, false, "AllowGroups applies too"); assert.equal(policy.evaluateAccess(restricted, { username: "admin", groups: ["openssh users"], address: "10.2.3.4" }).allowed, true, "group names with spaces are one entry"); assert.equal(policy.evaluateAccess(policy.parseEffective("allowusers ops@*.example.com"), { username: "ops", address: "10.0.0.1" }).allowed, null, "hostnames cannot be evaluated without DNS"); assert.deepEqual(policy.listenerFamilies(restricted), { ipv4: true, ipv6: false }); assert.equal(policy.hostMatches("::/0", "203.0.113.5"), false); assert.equal(policy.hostMatches("0.0.0.0/0", "2001:db8::1"), false); assert.equal(policy.hostMatches("2001:db8::/32", "2001:db8::1"), true); assert.deepEqual(policy.verifyPolicyEffect({ ...policy.POLICY_DEFAULTS, password: "no", ipv4: "deny" }, "bob", dump, "IPv4"), ["IPv4: PasswordAuthentication is yes"]); }); test("Core validation rejects names, paths and text that could reach a shell or passwd fields", () => { for (const value of ["bob;id", "$(id)", "`id`", "a b", "-rf", "Root", "1abc", "x".repeat(33), "", "bo\nb", "../x"]) { assert.throws(() => validateName(value), { code: "USERS_NAME_INVALID" }, value); } assert.equal(validateName("_svc-1"), "_svc-1"); for (const value of ["/bin/sh;id", "bin/sh", "/bin/../tmp/x", "/bin/s h", "/bin/$(x)"]) assert.throws(() => validateShell(value), { code: "USERS_SHELL_INVALID" }, value); for (const value of ["/", "/etc", "/etc/x", "/usr/local/x", "/home/../etc", "/home/a b", "home/x", "/home/$(id)", "/root", "/var", "/proc/self"]) { assert.throws(() => validateHome(value), { code: "USERS_HOME_INVALID" }, value); } assert.equal(validateHome("/home/novak/"), "/home/novak"); assert.equal(validateHome("/var/www/site"), "/var/www/site"); for (const value of ["a:b", "line\nbreak", "x".repeat(101), "back\\slash"]) assert.throws(() => validateComment(value), { code: "USERS_COMMENT_INVALID" }); assert.equal(validateComment("Jiří Novák, IT"), "Jiří Novák, IT"); }); test("remote helper script prints no password hashes and keeps secrets out of argv", () => { const script = fs.readFileSync(path.join(__dirname, "../src/main/users/remoteUsers.sh"), "utf8"); assert.match(script, /Password hashes never leave the server/); assert.doesNotMatch(script, /getent shadow[^\n]*\$2[^\n]*print \$2/); assert.match(script, /printf '%s:%s\\n' "\$1" "\$secret" \| chpasswd/); assert.match(script, /read_frame SECRET/); assert.doesNotMatch(script, /\beval\b/); const sections = splitSections("\x1eCHJ_SECTION=meta\nuid=0\n\x1eCHJ_SECTION=passwd\nroot:x:0:0::/root:/bin/sh\n"); assert.deepEqual(Object.keys(sections), ["meta", "passwd"]); }); class FakeChannel extends EventEmitter { constructor() { super(); this.stderr = new EventEmitter(); this.writes = []; this.ended = false; this.closed = false; } write(data) { this.writes.push(String(data)); this.emit("input", String(data)); } end() { if (this.ended) return; this.ended = true; this.emit("input-end"); } close() { if (this.closed) return; this.closed = true; setImmediate(() => this.emit("close", null)); } out(text) { this.emit("data", Buffer.from(text)); } err(text) { this.stderr.emit("data", Buffer.from(text)); } exit(code) { if (!this.closed) { this.closed = true; setImmediate(() => this.emit("close", code)); } } } function managerWith(username, scenario) { const manager = new SessionManager({ profileService: {}, latencyMonitor: { start: () => () => {} } }); const commands = []; const channels = []; const client = { exec(command, callback) { commands.push(command); const channel = new FakeChannel(); channels.push(channel); callback(null, channel); setImmediate(() => { if (command.includes("sudoNonInteractive")) { channel.out(`user=${username}\nuid=${username === "root" ? 0 : 1000}\ngroups=${username},sudo\nclient=198.51.100.7\nsudoInstalled=1\nsudoNonInteractive=1\nsudoMessage=sudo: a password is required\n`); channel.exit(0); } else scenario(channel, command); }); } }; manager.sessions.set("s1", { sessionId: "s1", profileId: "p1", host: "server.test", username, state: "connected", stream: {}, client, decoder: { end: () => "" } }); return { manager, executor: new PrivilegedExecutor({ sessionManager: manager }), commands, channels }; } test("privileged executor sends the sudo password only after sudo prompts and frames only after READY", async () => { const { executor, commands, channels } = managerWith("admin", (channel) => { channel.once("input", (password) => { assert.equal(password, "s3cret\n"); channel.once("input", (frame) => { channel.out(`got ${frame}`); channel.exit(0); }); channel.out("\x1eCHJ_READY\n"); }); channel.err(SUDO_PROMPT); }); const result = await executor.run("s1", { script: "echo fixed", args: ["inventory", "a'b"], frames: [{ tag: "SECRET", data: "account-pass" }], sudoPassword: "s3cret" }); assert.match(commands[1], /^LC_ALL=C sudo -S -p '\[chj-sudo-auth\]' -- sh -c 'echo fixed' chj 'inventory' 'a'"'"'b'$/); assert.ok(commands.every((command) => !command.includes("s3cret") && !command.includes("account-pass")), "secrets never appear in argv"); assert.equal(channels[1].writes[1], `CHJSECRET:${Buffer.from("account-pass").toString("base64")}\n`); assert.match(result.stdout, /got CHJSECRET:/); assert.equal((await executor.probe("s1")).mode, "sudo-password"); assert.equal((await executor.probe("s1")).verified, true); }); test("privileged executor reports missing, wrong and insufficient sudo authorization without hanging", async () => { let scenario = (channel) => { channel.on("input-end", () => channel.exit(1)); channel.err(SUDO_PROMPT); }; const { executor, channels } = managerWith("admin", (channel, command) => scenario(channel, command)); await assert.rejects(() => executor.run("s1", { script: "x" }), { code: "SUDO_PASSWORD_REQUIRED" }); assert.equal(channels[1].writes.length, 0, "no input without a password"); scenario = (channel) => { channel.once("input", () => setImmediate(() => channel.err(`Sorry, try again.\n${SUDO_PROMPT}`))); channel.on("input-end", () => channel.exit(1)); channel.err(SUDO_PROMPT); }; await assert.rejects(() => executor.run("s1", { script: "x", sudoPassword: "wrong", frames: [{ tag: "DATA", data: "payload" }] }), { code: "SUDO_PASSWORD_INCORRECT" }); assert.deepEqual(channels[2].writes, ["wrong\n"], "the frame is never sent to a sudo password prompt"); scenario = (channel) => { channel.err("sudo: sorry, you must have a tty to run sudo\n"); channel.exit(1); }; await assert.rejects(() => executor.run("s1", { script: "x" }), { code: "SUDO_TTY_REQUIRED" }); scenario = (channel) => { channel.once("input", () => { channel.err("admin is not in the sudoers file.\n"); channel.exit(1); }); channel.err(SUDO_PROMPT); }; await assert.rejects(() => executor.run("s1", { script: "x", sudoPassword: "right" }), { code: "SUDO_NOT_PERMITTED" }); const before = channels.length; await assert.rejects(() => executor.run("s1", { script: "x", sudoPassword: "right" }), { code: "SUDO_NOT_PERMITTED" }); assert.equal(channels.length, before, "a verified lack of sudo is not retried or prompted again"); }); test("privileged executor maps remote error markers and survives disconnects", async () => { let scenario = (channel) => { channel.out("\x1eCHJ_READY\n\x1eCHJ_ERR=PROTECTED_USER\n"); channel.err("The root account is protected.\n"); channel.exit(3); }; const { manager, executor, commands } = managerWith("root", (channel, command) => scenario(channel, command)); await assert.rejects(() => executor.run("s1", { script: "x", errorPrefix: "USERS_" }), { code: "USERS_PROTECTED_USER", message: "The root account is protected." }); assert.ok(commands[1].startsWith("LC_ALL=C sh -c "), "root sessions do not use sudo"); scenario = (channel) => { channel.out("\x1eCHJ_READY\n\x1eCHJ_STAGED\n"); }; const pending = executor.run("s1", { script: "x", interact: async (io) => { await io.waitFor(/\x1eCHJ_STAGED\n/); await io.waitFor(/never/, 60000); } }); setTimeout(() => manager._finalize(manager.sessions.get("s1"), "test"), 20); await assert.rejects(() => pending, { code: "SESSION_NOT_CONNECTED" }); }); test("last working administrator and protected accounts cannot be removed", () => { const service = new UserAdminService({ sessionManager: {}, privilegedExecutor: {} }); const inventory = (mode) => ({ privilege: { mode }, users: [ { username: "root", uid: 0, sudo: "full", loginShell: true, expired: false }, { username: "alice", uid: 1000, sudo: "full", loginShell: true, expired: false }, { username: "bob", uid: 1001, sudo: "none", loginShell: true, expired: false }, { username: "carol", uid: 1002, sudo: "full", loginShell: true, expired: true } ] }); assert.throws(() => service._assertNotLastAdmin(inventory("sudo-password"), { username: "alice" }, "delete", {}), { code: "USERS_LAST_ADMIN" }); assert.throws(() => service._assertNotLastAdmin(inventory("sudo-password"), { username: "alice" }, "shell", { shell: "/usr/sbin/nologin" }), { code: "USERS_LAST_ADMIN" }); service._assertNotLastAdmin(inventory("sudo-password"), { username: "alice" }, "shell", { shell: "/bin/bash" }); service._assertNotLastAdmin(inventory("sudo-password"), { username: "bob" }, "delete", {}); service._assertNotLastAdmin(inventory("root"), { username: "alice" }, "delete", {}); for (const protection of ["root", "system", "session"]) assert.throws(() => service._assertMutable({ protection }, "delete"), { code: "USERS_PROTECTED_USER" }); service._assertMutable({ protection: null }, "delete"); }); test("users plugin UI renders server data as text and asks for sudo only on demand", () => { const root = path.join(__dirname, "../src/main/firstPartyPlugins/chj.users/0.0.1"); const script = fs.readFileSync(path.join(root, "ui/index.js"), "utf8"); const html = fs.readFileSync(path.join(root, "ui/index.html"), "utf8"); const manifest = JSON.parse(fs.readFileSync(path.join(root, "manifest.json"), "utf8")); assert.doesNotMatch(script, /\.(innerHTML|outerHTML)\s*=|insertAdjacentHTML\(|document\.write\(/); assert.doesNotMatch(html, /id="sudoPassword"[^>]*class="toolbar/); assert.match(html, / { const legacy = policy.generateManagedConfig(new Map([["bob", policy.normalizePolicy({ password: "no" })]])); assert.doesNotMatch(legacy, /permitRootLogin/, "files written before the option stay byte-identical"); assert.equal(policy.parseManagedConfig(legacy).consistent, true); const withRoot = policy.generateManagedConfig(new Map([["root", policy.normalizePolicy({ permitRootLogin: "prohibit-password", ipv6: "deny" }, "root")]])); assert.match(withRoot, /# chj-policy root access=allow password=inherit pubkey=inherit ipv4=allow ipv6=deny permitRootLogin=prohibit-password/); assert.match(withRoot, /DenyUsers root@::\/0\nMatch User root\n\tPermitRootLogin prohibit-password\nMatch all\n$/); assert.equal(policy.parseManagedConfig(withRoot).policies.get("root").permitRootLogin, "prohibit-password"); assert.throws(() => policy.normalizePolicy({ permitRootLogin: "yes" }, "bob"), { code: "USERS_SSH_POLICY_INVALID" }); assert.throws(() => policy.normalizePolicy({ permitRootLogin: "maybe" }, "root"), { code: "USERS_SSH_POLICY_INVALID" }); const dump = policy.parseEffective("permitrootlogin without-password\npasswordauthentication yes\npubkeyauthentication yes"); assert.deepEqual(policy.verifyPolicyEffect({ ...policy.POLICY_DEFAULTS, permitRootLogin: "prohibit-password" }, "root", dump, "IPv4"), []); const evaluation = policy.evaluateAccess(dump, { username: "root", address: "192.0.2.1" }); assert.equal(evaluation.methods.password, false); assert.equal(evaluation.methods.publickey, true); assert.equal(policy.evaluateAccess(policy.parseEffective("permitrootlogin no\npubkeyauthentication yes"), { username: "root", address: "192.0.2.1" }).allowed, false); const service = new UserAdminService({ sessionManager: {}, privilegedExecutor: { probe: async () => { throw new Error("must not run"); } } }); await assert.rejects(() => service.sshPolicy("s", { username: "root", policy: { permitRootLogin: "yes" } }), { code: "USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED" }); await assert.rejects(() => service.sshPolicy("s", { username: "root", policy: { password: "yes" } }), { code: "USERS_ROOT_PASSWORD_CONFIRMATION_REQUIRED" }); });