"use strict"; const crypto = require("node:crypto"); const fs = require("node:fs"); const net = require("node:net"); const path = require("node:path"); const { SessionError } = require("../sessions/sessionManager"); const { BOOTSTRAP, bootstrapInput, encodedCommand, parseWindowsOutput } = require("../sessions/windowsPrivilegedExec"); const keys = require("./authorizedKeys"); const policyTools = require("./sshPolicy"); const windowsPolicy = require("./windowsSshPolicy"); const REMOTE_SCRIPT = fs.readFileSync(path.join(__dirname, "windows", "remoteUsers.ps1"), "utf8"); const ADMINS_SID = "S-1-5-32-544"; const LOCAL_SID = /^S-1-5-21-\d+-\d+-\d+-\d+$/; const GROUP_SID = /^S-1-5-(32-\d+|21-\d+-\d+-\d+-\d+)$/; const NAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._ -]{0,19}$/; const SAMPLE_IPV4 = "192.0.2.10"; const SAMPLE_IPV6 = "2001:db8::10"; const APPLY_TIMEOUT_SECONDS = 120; const FINAL_STATES = new Set(["confirmed", "rolled-back", "rollback-failed", "aborted"]); const ACTIONS = Object.freeze({ enable: "enable", disable: "disable", unexpire: "enable", expire: "disable", setPassword: "password", setFullName: "fullName", setComment: "fullName", setDescription: "description", addGroup: "group-add", removeGroup: "group-remove", grantAdmin: "admin-grant", revokeAdmin: "admin-revoke" }); function fail(code, message) { return new SessionError(code, message); } function cleanText(value, limit = 300) { return String(value ?? "").replace(/[\x00-\x1f\x7f]/g, " ").trim().slice(0, limit); } function validateWindowsName(value) { const name = String(value ?? "").trim(); if (!NAME_PATTERN.test(name) || /[. ]$/.test(name)) throw fail("USERS_NAME_INVALID", "Windows account names may contain letters, digits, space, '.', '_' or '-' (max. 20) and must not end with a dot or space."); return name; } function validateSid(value, pattern = LOCAL_SID, code = "USERS_SID_INVALID") { const sid = String(value ?? "").trim().toUpperCase(); if (!pattern.test(sid)) throw fail(code, "Invalid security identifier (SID)."); return sid; } function validatePassword(value) { const password = String(value ?? ""); if (!password || password.length > 256 || /[\0\r\n]/.test(password)) throw fail("USERS_PASSWORD_INVALID", "The password must contain 1-256 characters and no line breaks."); return password; } function validateText(value, limit, code) { const text = String(value ?? "").trim(); if (text.length > limit || /[\x00-\x1f\x7f]/.test(text)) throw fail(code, `The text may contain up to ${limit} characters without control characters.`); return text; } function familyAddresses(clientAddress) { const family = net.isIP(clientAddress); return { ipv4: family === 4 ? clientAddress : SAMPLE_IPV4, ipv6: family === 6 ? clientAddress : SAMPLE_IPV6, ipv4Sample: family !== 4, ipv6Sample: family !== 6 }; } function privilegeFromToken(token, system) { const mode = token.elevated ? "windows-elevated" : token.adminMember ? "windows-admin-unelevated" : "windows-standard"; return { platform: "windows", mode, verified: true, username: String(token.name || ""), sid: String(token.sid || ""), integrity: String(token.integrity || "unknown"), adminMember: token.adminMember === true, elevated: token.elevated === true, sudoInstalled: false, groups: Array.isArray(token.groupNames) ? token.groupNames.map((name) => String(name).toLowerCase()).slice(0, 256) : [], clientAddress: String(system?.sshClient || "").trim().split(/\s+/)[0] || "", message: "", checkedAt: new Date().toISOString() }; } // The account name used in sshd_config and in sshd -T for a local user ("codex", not "HOST\\codex"). function accountName(tokenName) { return String(tokenName || "").split("\\").pop(); } function decodeConfig(ssh) { return ssh?.configData ? Buffer.from(ssh.configData, "base64").toString("utf8") : ""; } class WindowsUserAdminService { constructor(options) { this.sessionManager = options.sessionManager; this.executor = options.windowsExecutor; this.logger = options.logger; } _run(sessionId, args, purpose, timeoutMs) { return this.executor.run(sessionId, { script: REMOTE_SCRIPT, args, purpose, timeoutMs }); } async privileges(sessionId) { const probe = await this._run(sessionId, { op: "probe" }, "users.windows.probe", 60000); return privilegeFromToken(probe.token, probe.system); } async inventory(sessionId, platform) { const raw = await this._run(sessionId, { op: "inventory" }, "users.windows.inventory", 120000); return this._mapInventory(sessionId, raw, platform); } _mapInventory(sessionId, raw, platform) { const privilege = privilegeFromToken(raw.token, raw.system); const config = decodeConfig(raw.ssh); const managed = raw.ssh.configReadable ? windowsPolicy.parseManagedPolicies(config) : null; const machinePrefix = (raw.users.find((user) => LOCAL_SID.test(user.sid))?.sid || "").replace(/-\d+$/, ""); const groups = raw.groups.map((group) => ({ name: String(group.name), sid: String(group.sid), description: cleanText(group.description, 200), members: group.members.map((member) => ({ sid: member.sid, name: cleanText(member.name, 120), source: member.source, objectClass: member.objectClass })) })); const users = raw.users.map((user) => { const memberOf = groups.filter((group) => group.members.some((member) => member.sid === user.sid)); const rid = Number(String(user.sid).split("-").pop()); const builtin = rid < 1000; const name = windowsPolicy.sshName(user.name); return { platform: "windows", username: user.name, sid: user.sid, rid, enabled: user.enabled === true, fullName: cleanText(user.fullName, 256), comment: cleanText(user.fullName, 256), description: cleanText(user.description, 256), source: user.source, lastLogon: user.lastLogon, passwordLastSet: user.passwordLastSet, passwordRequired: user.passwordRequired === true, passwordExpires: user.passwordExpires, accountExpires: user.accountExpires, admin: memberOf.some((group) => group.sid === ADMINS_SID), groups: memberOf.map((group) => group.name), groupSids: memberOf.map((group) => group.sid), system: builtin, builtin, protection: builtin ? "builtin" : user.sid === privilege.sid ? "session" : null, sshName: name, sshPolicy: name && managed ? managed.policies.get(name) || null : null }; }).sort((left, right) => Number(left.system) - Number(right.system) || left.username.localeCompare(right.username)); // Members that are not local accounts of this machine (domain, Microsoft Entra, other machines) are read-only. const external = new Map(); for (const group of groups) for (const member of group.members) { if (!/^S-1-5-21-/.test(member.sid) && !/^S-1-12-1-/.test(member.sid)) continue; if (machinePrefix && member.sid.startsWith(`${machinePrefix}-`)) continue; const entry = external.get(member.sid) || { sid: member.sid, name: member.name, source: member.source, groups: [] }; entry.groups.push(group.name); external.set(member.sid, entry); } return { platform: "windows", sessionId, privileged: privilege.elevated, privilege, server: { os: `${raw.system.caption} (${raw.system.version}${raw.system.displayVersion ? `, ${raw.system.displayVersion}` : ""})`, osId: "windows", installationType: raw.system.installationType, serverCore: /server core/i.test(raw.system.installationType), partOfDomain: raw.system.partOfDomain === true, domain: cleanText(raw.system.domain, 120), powershell: `${raw.system.psVersion} ${raw.system.psEdition}`, is64BitProcess: raw.system.is64BitProcess === true, defaultShell: platform?.defaultShell || "cmd.exe", sshLayout: managed?.analysis.supported ? "ok" : managed ? "unsupported" : "unreadable", sshVersion: cleanText(raw.ssh.version, 120), sshService: cleanText(raw.ssh.service, 40), managedPath: windowsPolicy.CONFIG_PATH, tools: [] }, shells: [], groups: groups.map(({ members, ...group }) => ({ ...group, members: members.map((member) => member.sid) })), adminGroups: ["Administrators"], externalIdentities: [...external.values()], managedPolicy: { state: managed ? (managed.policies.size ? "present" : "absent") : "unreadable", consistent: managed ? managed.consistent : false, users: managed ? [...managed.policies.keys()] : [] }, users, collectedAt: new Date().toISOString() }; } async _resolveTarget(sessionId, payload) { if (payload.sid) return { sid: validateSid(payload.sid) }; return { name: validateWindowsName(payload.name ?? payload.username) }; } async detail(sessionId, payload = {}, platform) { const target = await this._resolveTarget(sessionId, payload); const privilege = await this.privileges(sessionId); const addresses = familyAddresses(privilege.clientAddress); const raw = await this._run(sessionId, { op: "detail", ...target, addr4: addresses.ipv4, addr6: addresses.ipv6 }, "users.windows.detail", 120000); return this._mapDetail(sessionId, raw, privilege, addresses, platform); } _mapDetail(sessionId, raw, privilege, addresses, platform) { const session = this.sessionManager.sessionAuthInfo(sessionId); const user = raw.user; const rid = Number(String(user.sid).split("-").pop()); const groups = raw.groups.map((group) => ({ sid: group.sid, name: String(group.name) })); const admin = groups.some((group) => group.sid === ADMINS_SID); const name = windowsPolicy.sshName(user.name); const config = decodeConfig(raw.ssh); const managed = raw.ssh.configReadable ? windowsPolicy.parseManagedPolicies(config) : null; const policy = (name && managed?.policies.get(name)) || { ...policyTools.POLICY_DEFAULTS }; const WELL_KNOWN = { "S-1-5-32-544": "administrators", "S-1-5-32-545": "users", "S-1-5-32-585": "openssh users" }; const groupNames = [...new Set(groups.flatMap((group) => [group.name.toLowerCase(), WELL_KNOWN[group.sid]].filter(Boolean)))]; const effective = {}; const warnings = []; for (const [family, key] of [["ipv4", "effective4"], ["ipv6", "effective6"]]) { if (!raw[key] || !name) continue; const parsed = policyTools.parseEffective(raw[key].code === 0 ? raw[key].text : ""); if (raw[key].code !== 0) parsed.errors.push(cleanText(raw[key].text, 300)); const evaluation = policyTools.evaluateAccess(parsed, { username: name, groups: groupNames, address: addresses[family], keyboardInteractive: false }); effective[family] = { address: addresses[family], sample: addresses[`${family}Sample`], passwordAuthentication: parsed.values.passwordauthentication || "unknown", pubkeyAuthentication: parsed.values.pubkeyauthentication || "unknown", authenticationMethods: parsed.values.authenticationmethods || "any", allowed: evaluation.allowed, reasons: evaluation.reasons, methods: evaluation.methods, errors: parsed.errors.slice(0, 5) }; effective.listener ||= policyTools.listenerFamilies(parsed); } if (effective.listener && !effective.listener.ipv6) warnings.push("sshd does not listen on IPv6; IPv6 logins are impossible regardless of the account policy."); if (effective.listener && !effective.listener.ipv4) warnings.push("sshd does not listen on IPv4; IPv4 logins are impossible regardless of the account policy."); if (!name) warnings.push("The account name contains characters that cannot be used safely in sshd_config; CH-J does not manage its SSH policy."); if (managed && !managed.analysis.supported) warnings.push(`sshd_config cannot be edited safely: ${managed.analysis.problems.join("; ")}.`); if (managed && managed.analysis.supported && !managed.consistent) warnings.push("The CH-J sections in sshd_config were edited manually; CH-J will not overwrite them."); const keyFiles = (raw.keyFiles || []).map((file) => this._mapKeyFile(file, user.sid, raw.profilePath)); if (keyFiles.some((file) => file.shared)) warnings.push("Administrators share one administrators_authorized_keys file: every key in it can log in as any member of the Administrators group."); for (const file of keyFiles) for (const warning of file.aclWarnings) warnings.push(warning); const self = user.sid === privilege.sid; return { platform: "windows", sessionId, username: user.name, sid: user.sid, privileged: privilege.elevated, self, session: { authMethod: session.authMethod, username: privilege.username, sid: privilege.sid, clientAddress: privilege.clientAddress }, privilege, account: { platform: "windows", username: user.name, sid: user.sid, rid, enabled: user.enabled === true, fullName: cleanText(user.fullName, 256), comment: cleanText(user.fullName, 256), description: cleanText(user.description, 256), source: user.source, lastLogon: user.lastLogon, passwordLastSet: user.passwordLastSet, passwordRequired: user.passwordRequired === true, passwordExpires: user.passwordExpires, accountExpires: user.accountExpires, profilePath: raw.profilePath || null, groups, admin, system: rid < 1000, protection: rid < 1000 ? "builtin" : self ? "session" : null }, ssh: { available: raw.ssh.installed === true, version: cleanText(raw.ssh.version, 160), service: `${raw.ssh.service || "unknown"} / ${raw.ssh.startType || "unknown"}`, configPath: raw.ssh.configPath, layout: managed?.analysis.supported ? "ok" : managed ? "unsupported" : "unreadable", layoutSupported: Boolean(managed?.analysis.supported), layoutProblems: managed?.analysis.problems || ["sshd_config is not readable"], syntaxOk: raw.syntax ? raw.syntax.code === 0 : null, syntaxMessage: cleanText(raw.syntax?.output, 400), managed: { state: managed ? (managed.policies.size ? "present" : "absent") : "unreadable", consistent: managed ? managed.consistent : false, path: windowsPolicy.CONFIG_PATH }, restartRequired: true, sshName: name, policy, effective, warnings }, keys: { files: keyFiles.map(({ raw: _raw, ...file }) => file), home: raw.profilePath || null }, system: platform || null, collectedAt: new Date().toISOString(), _keyFiles: keyFiles, _config: { text: config, sha256: raw.ssh.configSha256, managed }, _addresses: addresses, _groups: groupNames }; } _mapKeyFile(file, ownerSid, profilePath) { const rawBytes = file.data ? Buffer.from(file.data, "base64") : Buffer.alloc(0); let reason = null; if (!file.path || file.type === "unresolved") reason = "unsupported-token"; else if (file.type === "needs-elevation") reason = "needs-elevation"; else if (file.reparse || file.type === "reparse") reason = "symlink"; else if (file.type === "other") reason = "not-regular"; else if (file.type === "unreadable") reason = "unreadable"; else if (file.tooLarge) reason = "too-large"; else if (file.type === "absent-dir" && (file.shared || !profilePath)) reason = "profile-missing"; else if (!["file", "absent", "absent-dir"].includes(file.type)) reason = "unknown"; const allowed = new Set(["S-1-5-18", ADMINS_SID, ...(file.shared ? [] : [ownerSid])]); const aclWarnings = []; for (const entry of file.acl || []) { if (entry.type === "Allow" && !allowed.has(entry.sid) && /Write|Modify|FullControl|ChangePermissions|TakeOwnership/.test(entry.rights)) { aclWarnings.push(`${file.path}: ${entry.sid} has ${entry.rights}; Windows OpenSSH ignores key files writable by other principals.`); } } return { path: file.path, spec: cleanText(file.spec, 200), type: file.type, shared: file.shared === true, owner: file.owner || null, acl: (file.acl || []).map((entry) => ({ sid: entry.sid, rights: cleanText(entry.rights, 120), type: entry.type, inherited: entry.inherited === true })), meta: file.owner ? `owner ${file.owner}` : "", dirMeta: "", sha256: file.sha256 || "absent", manageable: reason === null, reason, aclWarnings, entries: file.type === "file" ? keys.parseAuthorizedKeys(rawBytes) : [], raw: rawBytes }; } _assertMutable(account) { if (account.protection === "builtin") throw fail("USERS_PROTECTED_USER", "Built-in Windows accounts (Administrator, Guest, DefaultAccount, WDAGUtilityAccount) are protected."); if (account.protection === "session") throw fail("USERS_PROTECTED_USER", "The account used by the active SSH session is protected against changes that could lock you out."); if (account.source && !/^(Local|MicrosoftAccount)$/i.test(account.source)) throw fail("USERS_DOMAIN_READ_ONLY", "Domain and cloud identities are shown read-only; CH-J does not manage them."); } _requireElevated(privilege) { if (privilege.elevated) return; throw fail("USERS_WINDOWS_ELEVATION_REQUIRED", privilege.adminMember ? "The SSH session belongs to Administrators, but its token is not elevated (UAC filtered). CH-J does not bypass UAC." : "The SSH account is not a local administrator; administrative operations are not available."); } async manage(sessionId, payload = {}, platform) { const privilege = await this.privileges(sessionId); this._requireElevated(privilege); const requested = String(payload.action || ""); let result; if (requested === "create") { const name = validateWindowsName(payload.username); const groups = [...new Set((Array.isArray(payload.groups) ? payload.groups : []).map((sid) => validateSid(sid, GROUP_SID, "USERS_GROUP_INVALID")))]; result = await this._run(sessionId, { op: "create", name, fullName: validateText(payload.fullName ?? payload.comment, 256, "USERS_COMMENT_INVALID"), description: validateText(payload.description, 48, "USERS_COMMENT_INVALID"), admin: payload.admin === true, groups, ...(payload.password ? { password: validatePassword(payload.password) } : {}) }, "users.windows.create", 120000); } else { const sid = validateSid(payload.sid); const inventory = await this.inventory(sessionId, platform); const account = inventory.users.find((user) => user.sid === sid); if (!account) throw fail("USERS_USER_NOT_FOUND", "The local account does not exist."); this._assertMutable(account); if (requested === "delete") { result = await this._run(sessionId, { op: "delete", sid, removeProfile: payload.removeProfile === true || payload.removeHome === true }, "users.windows.delete", 120000); } else { const action = ACTIONS[requested]; if (!action) throw fail("USERS_NOT_SUPPORTED_ON_PLATFORM", "This operation exists only on Linux (shell, home directory, primary group, password lock)."); const args = { op: "account", action, sid }; if (action === "password") args.password = validatePassword(payload.password); if (action === "fullName") args.value = validateText(payload.fullName ?? payload.comment, 256, "USERS_COMMENT_INVALID"); if (action === "description") args.value = validateText(payload.description, 48, "USERS_COMMENT_INVALID"); if (action === "group-add" || action === "group-remove") args.group = validateSid(payload.group, GROUP_SID, "USERS_GROUP_INVALID"); result = await this._run(sessionId, args, `users.windows.${action}`, 120000); } } this.logger?.warn("Remote Windows account action completed.", { sessionId, action: requested, result: result.result }); const warnings = []; if (requested === "delete" && payload.sid) { try { await this._cleanupPolicy(sessionId, payload, warnings); } catch (error) { warnings.push(`CH-J SSH rules of the deleted account remain: ${error.message}`); } } const inventory = await this.inventory(sessionId, platform); return { action: requested, username: payload.username || "", sid: result.sid || payload.sid || null, result: warnings.length && result.result === "ok" ? "partial" : result.result, steps: result.steps || [], warnings, inventory, users: inventory.users }; } async _cleanupPolicy(sessionId, payload, warnings) { const name = windowsPolicy.sshName(payload.username); if (!name) return; const session = await this.privileges(sessionId); const context = await this.detail(sessionId, { sid: session.sid }); if (!context._config.managed?.policies.has(name)) return; warnings.push(...(await this._applyPolicy(sessionId, context, name, null, payload.probeCredentials || {})).warnings); } async keys(sessionId, payload = {}, platform) { const action = String(payload.action || ""); if (!["add", "remove"].includes(action)) throw fail("USERS_ACTION_INVALID", "Unsupported key action."); const target = await this._resolveTarget(sessionId, payload); const detail = await this.detail(sessionId, target, platform); const session = this.sessionManager.sessionAuthInfo(sessionId); if (detail.account.protection === "builtin") throw fail("USERS_PROTECTED_USER", "Keys of built-in accounts are shown read-only."); if (!detail.self) this._requireElevated(detail.privilege); let file; let content; if (action === "add") { const key = keys.normalizePublicKey(payload.publicKey, payload.comment); for (const candidate of detail._keyFiles) { if (candidate.entries.some((entry) => entry.blob === key.blob)) throw fail("USERS_KEY_DUPLICATE", `This public key is already authorized in ${candidate.path}.`); } file = detail._keyFiles[0]; if (!file?.manageable) throw fail("USERS_KEYS_FILE_UNMANAGEABLE", `The effective authorized_keys file cannot be managed safely (${file?.reason || "not found"}).`); content = keys.appendKey(file.raw, key); } else { file = detail._keyFiles.find((candidate) => candidate.path === payload.path); if (!file?.manageable) throw fail("USERS_KEYS_FILE_UNMANAGEABLE", "This key file cannot be changed safely."); const sessionUsesFile = detail.self || (file.shared && detail.privilege.adminMember); if (sessionUsesFile && session.authMethod === "privateKey") { if (session.publicKeyFingerprint && session.publicKeyFingerprint === payload.fingerprint) throw fail("USERS_KEY_IN_USE", "This key is used by the active CH-J session and is not removed."); if (file.entries.filter((entry) => entry.valid).length <= 1) throw fail("USERS_KEY_LAST", "The last key of the file used by the active session is not removed."); } content = keys.removeKey(file.raw, payload.line, String(payload.fingerprint || "")); } await this._run(sessionId, { op: "keys-write", sid: detail.sid, path: file.path, expected: file.type === "file" ? keys.sha256Hex(file.raw) : "absent", data: content.toString("base64"), addr4: detail._addresses.ipv4, addr6: detail._addresses.ipv6 }, `users.windows.keys.${action}`, 120000); this.logger?.warn("Remote Windows authorized keys changed.", { sessionId, action, path: file.path, shared: file.shared }); const refreshed = await this.detail(sessionId, { sid: detail.sid }, platform); return { action, username: detail.username, sid: detail.sid, path: file.path, shared: file.shared, detail: refreshed }; } async sshPolicy(sessionId, payload = {}, platform) { const target = await this._resolveTarget(sessionId, payload); const policy = policyTools.normalizePolicy(payload.policy || {}, "windows-account"); const detail = await this.detail(sessionId, target, platform); this._requireElevated(detail.privilege); if (detail.account.protection === "builtin") throw fail("USERS_PROTECTED_USER", "SSH access of built-in accounts is not managed by CH-J."); if (!detail.ssh.sshName) throw fail("USERS_SSH_NAME_UNSUPPORTED", "The account name cannot be used safely in sshd_config."); const result = await this._applyPolicy(sessionId, detail, detail.ssh.sshName, policy, payload.probeCredentials || {}, detail.self); let refreshed = null; try { refreshed = await this.detail(sessionId, { sid: detail.sid }, platform); } catch (error) { if (error.code !== "SESSION_NOT_CONNECTED") throw error; result.sessionLost = true; } return { ...result, detail: refreshed }; } // Stage (sshd -t, sshd -T -f candidate) -> commit through a SYSTEM scheduled task that arms the rollback // before restarting sshd -> confirm from a NEW SSH connection -> otherwise the task restores the backup. async _applyPolicy(sessionId, detail, name, policy, probeCredentials, self = false) { const privilege = detail.privilege; if (!detail.ssh.available) throw fail("USERS_SSHD_MISSING", "OpenSSH Server (sshd.exe) is not installed."); if (detail.ssh.syntaxOk === false) throw fail("USERS_SSH_CONFIG_ALREADY_INVALID", `The current sshd_config is invalid: ${detail.ssh.syntaxMessage}`); const { text, sha256, managed } = detail._config; if (!managed) throw fail("USERS_SSH_LAYOUT_UNSUPPORTED", "sshd_config is not readable."); const policies = new Map(managed.policies); if (!policy || policyTools.isDefaultPolicy(policy)) policies.delete(name); else policies.set(name, policy); let content; try { content = windowsPolicy.renderConfig(text, policies); } catch (error) { throw fail(error.code || "USERS_SSH_LAYOUT_UNSUPPORTED", error.message); } if (content === text) return { changed: false, applied: false, policy: policy || { ...policyTools.POLICY_DEFAULTS }, warnings: [] }; const clientAddress = privilege.clientAddress; if (!net.isIP(clientAddress)) throw fail("USERS_SSH_CLIENT_UNKNOWN", "The client address of the SSH session is unknown, so lockout protection cannot be verified."); // Every Windows change restarts sshd, so a fresh login is always required to keep it. const requirements = this.sessionManager.probeLoginRequirements(sessionId, probeCredentials); if (!requirements.ready) throw fail("USERS_SSH_PROBE_CREDENTIALS_REQUIRED", `Windows OpenSSH must be restarted; a test login (${requirements.missing}) is required to keep the change.`); const target = this.sessionManager.connectionTarget(sessionId); const session = this.sessionManager.sessionAuthInfo(sessionId); const sessionName = windowsPolicy.sshName(accountName(privilege.username)) || accountName(privilege.username).toLowerCase(); const method = session.authMethod === "privateKey" ? "publickey" : "password"; const addresses = detail._addresses; const tuples = []; if (policy) tuples.push({ user: name, addr: addresses.ipv4 }, { user: name, addr: addresses.ipv6 }); tuples.push({ user: sessionName, addr: clientAddress }); const staged = await this._run(sessionId, { op: "ssh-stage", expected: sha256, data: Buffer.from(content, "utf8").toString("base64"), tuples }, "users.windows.ssh-stage", 120000); if (staged.syntaxCode !== 0) throw fail("USERS_SSH_CONFIG_INVALID", cleanText(staged.syntaxOutput, 400) || "sshd -t rejected the new configuration."); const parse = (entry) => { const parsed = policyTools.parseEffective(entry?.code === 0 ? entry.text : ""); if (!entry || entry.code !== 0) parsed.errors.push(cleanText(entry?.text || "missing", 200)); return parsed; }; const problems = []; if (policy) for (const [family, label] of [["ipv4", "IPv4"], ["ipv6", "IPv6"]]) { const effective = parse(staged.after[`${name}|${addresses[family]}`]); if (effective.errors.length) problems.push(`${label}: sshd -T did not return a usable result`); else problems.push(...policyTools.verifyPolicyEffect(policy, name, effective, label, { keyboardInteractive: false, ipv4Pattern: policyTools.WINDOWS_IPV4_PATTERN, ipv6Pattern: policyTools.WINDOWS_IPV6_PATTERN })); // With UseDNS the host part is also matched against reverse-DNS names, where *.*.*.* is not IPv4-specific. if ((policy.ipv4 === "deny" || policy.ipv6 === "deny") && effective.values.usedns === "yes") problems.push(`${label}: UseDNS yes makes address-family rules ambiguous`); } if (problems.length) throw fail("USERS_SSH_POLICY_NOT_EFFECTIVE", `Existing sshd rules take precedence (${problems.join("; ")}). Nothing was changed.`); const sessionKey = `${sessionName}|${clientAddress}`; const before = parse(staged.before[sessionKey]); const after = parse(staged.after[sessionKey]); const context = { username: sessionName, groups: detail.self ? detail._groups : privilege.groups, address: clientAddress, method, keyboardInteractive: false }; const evaluationAfter = policyTools.evaluateAccess(after, context); if (after.errors.length || evaluationAfter.methodAllowed === false || evaluationAfter.allowed === false) { throw fail("USERS_SSH_LOCKOUT_PREVENTED", `The active session (${method} from ${clientAddress}) would no longer be allowed to log in. Nothing was changed.`); } if (!self) { const comparable = (effective) => JSON.stringify({ evaluation: policyTools.evaluateAccess(effective, context), settings: Object.entries(effective.values).filter(([key]) => !["denyusers", "allowusers", "denygroups", "allowgroups"].includes(key)).sort() }); if (comparable(before) !== comparable(after)) throw fail("USERS_SSH_LOCKOUT_PREVENTED", "The change would alter the effective SSH access of the active session account. Nothing was changed."); } const nonce = crypto.randomBytes(24).toString("hex"); const commit = await this._run(sessionId, { op: "ssh-commit", id: staged.id, nonce, expected: sha256, candidateSha256: staged.candidateSha256, timeoutSeconds: APPLY_TIMEOUT_SECONDS }, "users.windows.ssh-commit", 120000); this.logger?.warn("Windows sshd change armed with rollback guard.", { sessionId, id: staged.id, state: commit.state }); if (commit.state !== "awaiting-confirmation") { throw fail("USERS_SSH_ROLLED_BACK", `The change was not kept (${commit.state}): ${cleanText(commit.message, 300)}`); } const confirmInput = (confirm, waitSeconds) => bootstrapInput(REMOTE_SCRIPT, { op: "ssh-confirm", id: staged.id, nonce, confirm, waitSeconds }); let confirmation; try { const fresh = await this.sessionManager.freshExec(target, probeCredentials, { command: encodedCommand(BOOTSTRAP), input: confirmInput(true, 25), timeoutMs: 90000, acceptAnyExit: true }); confirmation = parseWindowsOutput(fresh.stdout, fresh.stderr, fresh.code); } catch (error) { const rollback = await this._awaitRollback(sessionId, target, probeCredentials, confirmInput(false, APPLY_TIMEOUT_SECONDS + 30)); throw fail("USERS_SSH_PROBE_FAILED", `${error.message} The rollback guard reported: ${rollback}`); } if (confirmation.state !== "confirmed") throw fail("USERS_SSH_ROLLED_BACK", `The change was not kept (${confirmation.state}): ${cleanText(confirmation.message, 300)}`); this.logger?.warn("Windows sshd change confirmed by a new SSH login.", { sessionId, id: staged.id }); const warnings = []; try { this.sessionManager._requireConnected(sessionId); } catch { warnings.push("The original SSH session was closed by the sshd restart. Reconnect in the main window."); } return { changed: true, applied: true, probed: true, restarted: true, policy: policy || { ...policyTools.POLICY_DEFAULTS }, applyId: staged.id, warnings }; } // Waits for the server-side guard to restore the configuration, through the old session if it is still alive. async _awaitRollback(sessionId, target, credentials, input) { try { this.sessionManager._requireConnected(sessionId); const status = await this.executor.run(sessionId, { script: REMOTE_SCRIPT, args: JSON.parse(Buffer.from(input.split("\n")[1], "base64").toString("utf8")), timeoutMs: (APPLY_TIMEOUT_SECONDS + 60) * 1000 }); return `${status.state} (${cleanText(status.message, 200)})`; } catch {} try { await new Promise((resolve) => setTimeout(resolve, (APPLY_TIMEOUT_SECONDS + 15) * 1000)); const fresh = await this.sessionManager.freshExec(target, credentials, { command: encodedCommand(BOOTSTRAP), input, timeoutMs: (APPLY_TIMEOUT_SECONDS + 60) * 1000, acceptAnyExit: true }); const status = parseWindowsOutput(fresh.stdout, fresh.stderr, fresh.code); return `${status.state} (${cleanText(status.message, 200)})`; } catch (error) { return `unknown (${cleanText(error.message, 200)}); the scheduled task restores the previous sshd_config automatically after ${APPLY_TIMEOUT_SECONDS} s`; } } } module.exports = { ADMINS_SID, APPLY_TIMEOUT_SECONDS, REMOTE_SCRIPT, WindowsUserAdminService, privilegeFromToken, validateSid, validateWindowsName };