Files
NetMesh/electron/bridges/externalSshHostKeyPolicy.cjs
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

941 lines
32 KiB
JavaScript

/**
* Host-key policy helpers for external OpenSSH-driven protocols (Mosh, ET).
*
* Netcatty's in-app SSH path uses ssh2 + hostKeyVerifier with a renderer
* confirmation dialog. Mosh and Eternal Terminal bootstrap via system
* OpenSSH instead, so they cannot share that dialog path. They still need
* the vault known_hosts snapshot for MITM protection: keys the user already
* trusted through Netcatty SSH must reject when the live server presents a
* different key of the same type.
*
* Strategy (aligned with issue #2501 user priority — key-change intercept):
* - When verifyHostKeys is enabled and the vault has usable public-key
* blobs, build one authoritative known_hosts file that contains:
* 1. vault pins (sole source of truth for those hosts), and
* 2. OpenSSH default global + user known_hosts lines for hosts that
* are NOT vault-pinned.
* Setting GlobalKnownHostsFile=/vault-only would drop admin pins; unioning
* vault with the full system files would let a system K2 override vault K1
* because OpenSSH accepts an exact match from ANY trust source.
* - Point both UserKnownHostsFile and GlobalKnownHostsFile at that
* authoritative snapshot (or empty Global) so no unfiltered system file
* remains in the search path.
* - ET uses StrictHostKeyChecking=accept-new (SSH_ASKPASS cannot answer
* interactive yes/no). Mosh uses explicit StrictHostKeyChecking=ask so a
* permissive user ssh_config cannot disable verification.
* - When verifyHostKeys is false, force StrictHostKeyChecking=no and point
* both trust files at an empty snapshot (OpenSSH still consults
* known_hosts under `no` for password-auth MITM protection).
* - Path-valued option values are quoted when they contain whitespace so
* OpenSSH does not split them into multiple filenames.
*/
const crypto = require("node:crypto");
const path = require("node:path");
const os = require("node:os");
const { execFileSync } = require("node:child_process");
const formatVaultKnownHostLine = (knownHost, { hostnameOverride, portOverride, bareHostField = false } = {}) => {
const hostname = String(hostnameOverride || knownHost?.hostname || "").trim();
if (!hostname) return null;
const port = Number.isFinite(portOverride)
? Number(portOverride)
: (Number.isFinite(knownHost.port) ? Number(knownHost.port) : 22);
// HostKeyAlias pins are looked up by alias name only (default port form).
// Resolved HostName pins keep the connection port encoding.
const hostField = bareHostField
? hostname
: (port !== 22 ? `[${hostname}]:${port}` : hostname);
const pubKey = String(knownHost.publicKey || "").trim();
const parts = pubKey.split(/\s+/);
let keyType = typeof knownHost.keyType === "string" ? knownHost.keyType.trim() : "";
let keyBlob = "";
if (parts.length >= 2 && /^ssh-|^ecdsa-|^sk-/.test(parts[0])) {
keyType = parts[0];
keyBlob = parts[1];
} else if (parts.length === 1 && parts[0].length > 0 && !/^SHA256:/i.test(parts[0])) {
// One-token publicKey may be a bare base64 key blob — or a legacy
// fingerprint. Only accept values that decode as a real OpenSSH wire
// public key; fingerprint-only tokens must not become "vault pins".
try {
const blob = Buffer.from(parts[0], "base64");
if (blob.length < 8) return null;
const typeLen = blob.readUInt32BE(0);
if (typeLen <= 0 || typeLen > 128 || 4 + typeLen > blob.length) return null;
const decodedType = blob.subarray(4, 4 + typeLen).toString("ascii");
if (!/^[A-Za-z0-9@._+-]+$/.test(decodedType)) return null;
if (!/^ssh-|^ecdsa-|^sk-/.test(decodedType)) return null;
keyType = decodedType;
keyBlob = parts[0];
} catch {
return null;
}
} else {
return null;
}
if (!keyType || !keyBlob) return null;
return `${hostField} ${keyType} ${keyBlob}`;
};
/**
* @param {object[]} knownHosts
* @param {object} [opts]
* @param {string} [opts.connectionHostname] Netcatty connection hostname
* @param {number} [opts.connectionPort]
* @param {string} [opts.hostKeyAlias] Effective OpenSSH HostKeyAlias for the hop
* @param {string} [opts.resolvedHostName] Effective OpenSSH HostName for the hop
*/
const buildVaultKnownHostsContent = (knownHosts, opts = {}) => {
if (!Array.isArray(knownHosts) || knownHosts.length === 0) return "";
const connectionHostname = normalizeHostname(opts.connectionHostname);
const connectionPort = Number.isFinite(opts.connectionPort) ? Number(opts.connectionPort) : 22;
const hostKeyAlias = String(opts.hostKeyAlias || "").trim();
const resolvedHostName = String(opts.resolvedHostName || "").trim();
const lines = [];
for (const knownHost of knownHosts) {
const host = normalizeHostname(knownHost?.hostname);
const port = Number.isFinite(knownHost?.port) ? Number(knownHost.port) : 22;
const isConnectionHost = connectionHostname
&& host === connectionHostname
&& port === connectionPort;
let lineOpts;
if (isConnectionHost && hostKeyAlias) {
// HostKeyAlias pins are bare names (default-port form).
lineOpts = { hostnameOverride: hostKeyAlias, bareHostField: true };
} else if (
isConnectionHost
&& resolvedHostName
&& normalizeHostname(resolvedHostName) !== connectionHostname
) {
// Resolved HostName keeps the connection port encoding.
lineOpts = {
hostnameOverride: resolvedHostName,
portOverride: connectionPort,
bareHostField: false,
};
}
const line = formatVaultKnownHostLine(knownHost, lineOpts);
if (line) lines.push(line);
}
if (lines.length === 0) return "";
return `${lines.join("\n")}\n`;
};
/**
* Host/port pairs that vault entries pin. Used to filter system known_hosts
* so vault remains authoritative for those hosts.
*/
const extractVaultHostSelectors = (knownHosts) => {
const selectors = [];
if (!Array.isArray(knownHosts)) return selectors;
for (const knownHost of knownHosts) {
if (!formatVaultKnownHostLine(knownHost)) continue;
const hostname = String(knownHost.hostname || "").trim().toLowerCase();
if (!hostname) continue;
const port = Number.isFinite(knownHost.port) ? Number(knownHost.port) : 22;
selectors.push({ hostname, port });
}
return selectors;
};
const normalizeHostname = (value) => String(value || "").trim().toLowerCase();
const parseSshGScalar = (sshGOutput, directive) => {
const want = String(directive || "").toLowerCase();
if (!want) return "";
for (const rawLine of String(sshGOutput || "").split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const space = line.search(/\s/);
if (space <= 0) continue;
if (line.slice(0, space).toLowerCase() !== want) continue;
return line.slice(space).trim();
}
return "";
};
const buildHashLookupTokens = (hostname, port) => {
const raw = String(hostname || "").trim();
if (!raw) return [];
const variants = new Set([raw, raw.toLowerCase()]);
const tokens = new Set();
const usePort = Number.isFinite(port) && Number(port) !== 22;
for (const variant of variants) {
tokens.add(usePort ? `[${variant}]:${Number(port)}` : variant);
}
return [...tokens];
};
/**
* OpenSSH host-pattern matching for `*` / `?` (case-insensitive hostnames).
* Used when filtering system known_hosts so a wildcard pin cannot override a
* vault pin for a matching host.
*/
const openSshHostGlobMatches = (pattern, hostname) => {
const rawPattern = String(pattern || "");
const host = normalizeHostname(hostname);
if (!rawPattern || !host) return false;
// Escape regex metacharacters except the OpenSSH wildcards we translate.
let regexSource = "";
for (const ch of rawPattern.toLowerCase()) {
if (ch === "*") regexSource += ".*";
else if (ch === "?") regexSource += ".";
else if (/[.+^${}()|[\]\\]/.test(ch)) regexSource += `\\${ch}`;
else regexSource += ch;
}
try {
return new RegExp(`^${regexSource}$`).test(host);
} catch {
return false;
}
};
const plainHostPatternMatchesSelector = (token, selector) => {
if (!token || token.startsWith("!")) return false;
const bracket = token.match(/^\[([^\]]+)\]:(\d+)$/);
if (bracket) {
const patternHost = bracket[1];
const patternPort = Number.parseInt(bracket[2], 10);
if (patternPort !== selector.port) return false;
if (patternHost.includes("*") || patternHost.includes("?")) {
return openSshHostGlobMatches(patternHost, selector.hostname);
}
return normalizeHostname(patternHost) === selector.hostname;
}
if (token.includes("*") || token.includes("?")) {
// Bare wildcard patterns imply the default SSH port.
return selector.port === 22 && openSshHostGlobMatches(token, selector.hostname);
}
return normalizeHostname(token) === selector.hostname && selector.port === 22;
};
const hashedHostFieldMatchesSelector = (hostField, selector) => {
const field = String(hostField || "");
if (!field.startsWith("|1|")) return false;
const rest = field.slice(3);
const sep = rest.indexOf("|");
if (sep <= 0) return false;
let salt;
let expectedBuf;
try {
salt = Buffer.from(rest.slice(0, sep), "base64");
expectedBuf = Buffer.from(rest.slice(sep + 1), "base64");
} catch {
return false;
}
if (!salt.length || !expectedBuf.length) return false;
for (const token of buildHashLookupTokens(selector.hostname, selector.port)) {
let computed;
try {
computed = crypto.createHmac("sha1", salt).update(token).digest();
} catch {
continue;
}
if (
computed.length === expectedBuf.length
&& crypto.timingSafeEqual(computed, expectedBuf)
) {
return true;
}
}
return false;
};
/**
* OpenSSH known_hosts host-field matching: a host matches when it matches any
* positive pattern and does not match any negated (`!`) pattern. Patterns are
* comma-separated in the host field (see known_hosts(5)).
*/
const plainHostFieldMatchesSelector = (hostField, selector) => {
const patterns = String(hostField || "").split(",");
let matchedPositive = false;
for (const pattern of patterns) {
const token = pattern.trim();
if (!token) continue;
if (token.startsWith("!")) {
if (plainHostPatternMatchesSelector(token.slice(1), selector)) {
return false;
}
continue;
}
if (plainHostPatternMatchesSelector(token, selector)) {
matchedPositive = true;
}
}
return matchedPositive;
};
const hostFieldMatchesAnyVaultSelector = (hostField, selectors) => {
if (!selectors.length) return false;
const field = String(hostField || "").trim();
if (!field) return false;
if (field.startsWith("|1|")) {
return selectors.some((selector) => hashedHostFieldMatchesSelector(field, selector));
}
return selectors.some((selector) => plainHostFieldMatchesSelector(field, selector));
};
/**
* Rewrite a plain (non-hashed) host field by removing patterns that match
* vault-covered hosts, while keeping patterns that only cover other hosts.
* Returns null when nothing remains (caller should drop the line).
*/
const rewriteHostFieldExcludingVaultHosts = (hostField, vaultSelectors) => {
const field = String(hostField || "").trim();
if (!field || field.startsWith("|1|")) {
// Hashed fields are all-or-nothing: drop if they match any vault host.
if (field.startsWith("|1|") && hostFieldMatchesAnyVaultSelector(field, vaultSelectors)) {
return null;
}
return field || null;
}
const keptPatterns = [];
for (const pattern of field.split(",")) {
const token = pattern.trim();
if (!token) continue;
const positive = token.startsWith("!") ? token.slice(1) : token;
// Drop a pattern when its positive form matches a vault-covered host.
// Keep negation patterns only when their positive form is also kept.
const matchesVault = vaultSelectors.some((selector) =>
plainHostPatternMatchesSelector(positive, selector),
);
if (matchesVault) continue;
keptPatterns.push(token);
}
// A host field with only negations left is not a useful trust pin.
if (!keptPatterns.some((pattern) => !pattern.startsWith("!"))) return null;
return keptPatterns.join(",");
};
/**
* Drop or rewrite known_hosts lines that pin vault-covered hosts so vault keys
* are the only trust source for those hosts. OpenSSH accepts a match from ANY
* configured file; leaving a system K2 next to vault K1 would let K2 win.
*
* Multi-host lines such as `jump.example,target.example` keep the patterns
* that do not match vault hosts, so an unpinned hop is not accidentally
* converted to first-use trust.
*
* `@revoked` lines for vault-covered hosts are KEPT — admin revocations must
* remain authoritative and cannot be replaced by a vault pin alone.
*/
const filterKnownHostsContentExcludingVaultHosts = (content, vaultSelectors) => {
if (!content || !vaultSelectors?.length) {
return typeof content === "string" && content.trim() ? content.trimEnd() : "";
}
const kept = [];
for (const rawLine of String(content).split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) {
if (line.startsWith("#")) kept.push(rawLine.trimEnd());
continue;
}
let rest = line;
let markerPrefix = "";
let revoked = false;
while (rest.startsWith("@")) {
const spaceIdx = rest.search(/\s/);
if (spaceIdx < 0) {
rest = "";
break;
}
const marker = rest.slice(0, spaceIdx);
markerPrefix += `${marker} `;
if (marker === "@revoked") revoked = true;
rest = rest.slice(spaceIdx).trim();
}
if (!rest) {
kept.push(rawLine.trimEnd());
continue;
}
const parts = rest.split(/\s+/);
const hostField = parts[0];
const tail = parts.slice(1).join(" ");
if (hostFieldMatchesAnyVaultSelector(hostField, vaultSelectors)) {
if (revoked) {
kept.push(rawLine.trimEnd());
continue;
}
const rewrittenHost = rewriteHostFieldExcludingVaultHosts(hostField, vaultSelectors);
if (!rewrittenHost || !tail) continue;
kept.push(`${markerPrefix}${rewrittenHost} ${tail}`.trim());
continue;
}
kept.push(rawLine.trimEnd());
}
return kept.filter(Boolean).join("\n");
};
/**
* True when the vault contains a usable pin for at least one of the hosts
* involved in this connection (target and jump hosts). Used so ET only
* switches UserKnownHostsFile to a session snapshot when vault authority is
* actually needed — otherwise accept-new keeps writing to the persistent
* ~/.ssh/known_hosts.
*/
const vaultPinsConnectionHosts = (knownHosts, connectionHosts = []) => {
const vaultSelectors = extractVaultHostSelectors(knownHosts);
if (!vaultSelectors.length || !Array.isArray(connectionHosts) || connectionHosts.length === 0) {
return false;
}
for (const host of connectionHosts) {
const hostname = normalizeHostname(host?.hostname);
if (!hostname) continue;
const port = Number.isFinite(host?.port) ? Number(host.port) : 22;
if (vaultSelectors.some((selector) => selector.hostname === hostname && selector.port === port)) {
return true;
}
}
return false;
};
/**
* OpenSSH default GlobalKnownHostsFile locations.
* Matches `ssh -G -F /dev/null` on OpenSSH 9.x (Unix) and Windows OpenSSH.
*/
const getDefaultGlobalKnownHostsPaths = ({
platform = process.platform,
programData = process.env.ProgramData,
pathModule = path,
} = {}) => {
if (platform === "win32") {
const base = programData || "C:\\ProgramData";
return [
pathModule.join(base, "ssh", "ssh_known_hosts"),
pathModule.join(base, "ssh", "ssh_known_hosts2"),
];
}
return [
"/etc/ssh/ssh_known_hosts",
"/etc/ssh/ssh_known_hosts2",
];
};
const getDefaultUserKnownHostsPaths = ({
homedir = os.homedir(),
pathModule = path,
} = {}) => ([
pathModule.join(homedir, ".ssh", "known_hosts"),
pathModule.join(homedir, ".ssh", "known_hosts2"),
]);
const expandKnownHostsPath = (rawPath, { homedir = os.homedir(), pathModule = path } = {}) => {
const text = String(rawPath || "").trim();
if (!text) return "";
if (text === "~") return homedir;
if (text.startsWith("~/") || text.startsWith("~\\")) {
return pathModule.join(homedir, text.slice(2));
}
return text;
};
/**
* Split an ssh -G known_hosts path list. OpenSSH emits unquoted paths, so a
* single path containing spaces looks like multiple tokens. Prefer the full
* remainder when it exists on disk, otherwise greedily reassemble tokens into
* existing paths before falling back to whitespace splits.
*/
const splitKnownHostsPathList = (rest, {
fs: fsApi = null,
homedir = os.homedir(),
pathModule = path,
} = {}) => {
const raw = String(rest || "").trim();
if (!raw) return [];
const expand = (value) => expandKnownHostsPath(value, { homedir, pathModule });
const exists = (value) => {
if (!value) return false;
try {
return typeof fsApi?.existsSync === "function" ? fsApi.existsSync(value) : false;
} catch {
return false;
}
};
const expandedAll = expand(raw);
if (exists(expandedAll)) return [expandedAll];
const tokens = raw.split(/\s+/).filter(Boolean);
if (tokens.length <= 1) return tokens.map(expand).filter(Boolean);
const paths = [];
let index = 0;
while (index < tokens.length) {
let end = index;
let candidate = expand(tokens[index]);
// Grow the token span while the candidate path does not exist.
while (end + 1 < tokens.length && !exists(candidate)) {
end += 1;
candidate = expand(tokens.slice(index, end + 1).join(" "));
}
if (!exists(candidate)) {
// Nothing exists for this span; keep the single token and continue.
candidate = expand(tokens[index]);
end = index;
}
if (candidate) paths.push(candidate);
index = end + 1;
}
return paths;
};
/**
* Parse `ssh -G` output for a multi-path known_hosts directive
* (`globalknownhostsfile` / `userknownhostsfile`).
*/
const parseSshGKnownHostsPaths = (sshGOutput, directive, opts = {}) => {
const want = String(directive || "").toLowerCase();
if (!want) return null;
for (const rawLine of String(sshGOutput || "").split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const space = line.search(/\s/);
if (space <= 0) continue;
const key = line.slice(0, space).toLowerCase();
if (key !== want) continue;
const rest = line.slice(space).trim();
if (!rest) return [];
return splitKnownHostsPathList(rest, opts);
}
return null;
};
const runSshG = ({
hostname,
port,
username,
platform = process.platform,
execFileSyncFn = execFileSync,
sshCommand,
// Optional per-connection memo (Map). Used so target+jump discovery in one
// prepareEtSshEnvironment call can share probes without a process-lifetime
// cache that would serve stale HostName/HostKeyAlias after ssh_config edits.
memo = null,
} = {}) => {
const target = String(hostname || "").trim() || "localhost";
if (typeof execFileSyncFn !== "function") return "";
const cmd = sshCommand || "ssh";
const args = ["-G"];
// Pass port/user so %p / %r tokens in configured known_hosts paths expand
// the same way the real connection will.
if (Number.isFinite(port) && Number(port) > 0 && Number(port) !== 22) {
args.push("-p", String(Number(port)));
}
if (username) args.push("-l", String(username));
args.push(target);
const cacheKey = `${cmd}\0${args.join("\0")}`;
if (memo && typeof memo.get === "function" && memo.has(cacheKey)) {
return memo.get(cacheKey);
}
const output = execFileSyncFn(cmd, args, {
encoding: "utf8",
// Keep the timeout short so a hung Match exec cannot freeze the app long.
timeout: 1500,
windowsHide: true,
env: process.env,
});
if (memo && typeof memo.set === "function") {
memo.set(cacheKey, output);
}
return output;
};
/**
* Resolve the effective GlobalKnownHostsFile list for a target host via
* `ssh -G`, falling back to OpenSSH's built-in defaults when discovery fails.
* Required because administrators may set non-default GlobalKnownHostsFile
* paths in ssh_config; replacing GlobalKnownHostsFile with a vault snapshot
* without merging those paths would drop admin pins / @revoked entries.
*/
const resolveEffectiveGlobalKnownHostsPaths = ({
hostname,
port,
username,
platform = process.platform,
programData = process.env.ProgramData,
homedir = os.homedir(),
pathModule = path,
fs: fsApi = null,
execFileSyncFn = execFileSync,
sshCommand,
sshGOutput,
memo = null,
} = {}) => {
const defaults = getDefaultGlobalKnownHostsPaths({ platform, programData, pathModule });
try {
const output = sshGOutput != null
? sshGOutput
: runSshG({ hostname, port, username, platform, execFileSyncFn, sshCommand, memo });
const parsed = parseSshGKnownHostsPaths(output, "globalknownhostsfile", {
fs: fsApi,
homedir,
pathModule,
});
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
} catch {
// Discovery is best-effort; fall back to compiled-in defaults.
}
return defaults;
};
/**
* Resolve the effective UserKnownHostsFile list for a target host via
* `ssh -G`, falling back to the default ~/.ssh/known_hosts{,2} paths.
*/
const resolveEffectiveUserKnownHostsPaths = ({
hostname,
port,
username,
platform = process.platform,
homedir = os.homedir(),
pathModule = path,
fs: fsApi = null,
execFileSyncFn = execFileSync,
sshCommand,
sshGOutput,
memo = null,
} = {}) => {
const defaults = getDefaultUserKnownHostsPaths({ homedir, pathModule });
try {
const output = sshGOutput != null
? sshGOutput
: runSshG({ hostname, port, username, platform, execFileSyncFn, sshCommand, memo });
const parsed = parseSshGKnownHostsPaths(output, "userknownhostsfile", {
fs: fsApi,
homedir,
pathModule,
});
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
} catch {
// Best-effort.
}
return defaults;
};
const readKnownHostsFileContent = (fsApi, filePath) => {
if (!fsApi || !filePath) return "";
try {
if (typeof fsApi.existsSync === "function" && !fsApi.existsSync(filePath)) {
return "";
}
const content = fsApi.readFileSync(filePath, "utf8");
return typeof content === "string" && content.trim() ? content.trimEnd() : "";
} catch {
return "";
}
};
/**
* Build the authoritative known_hosts content used when vault pins exist.
* Returns "" when the vault has no usable pins (caller should leave OpenSSH
* defaults alone).
*/
const buildAuthoritativeKnownHostsContent = ({
knownHosts,
fs: fsApi,
hostname,
port,
username,
platform = process.platform,
programData = process.env.ProgramData,
homedir = os.homedir(),
pathModule = path,
globalPaths,
userPaths,
execFileSyncFn = execFileSync,
sshCommand,
memo = null,
} = {}) => {
// One ssh -G probe for path discovery and HostKeyAlias resolution.
let sshGOutput = "";
try {
sshGOutput = runSshG({
hostname,
port,
username,
platform,
execFileSyncFn,
sshCommand,
memo,
});
} catch {
sshGOutput = "";
}
// OpenSSH known_hosts lookup uses HostKeyAlias when set, otherwise the
// resolved HostName (which may differ from the connection alias).
const hostKeyAlias = parseSshGScalar(sshGOutput, "hostkeyalias");
const resolvedHostName = parseSshGScalar(sshGOutput, "hostname") || hostname;
const lookupHostName = hostKeyAlias || resolvedHostName;
const connectionPort = Number.isFinite(port) ? Number(port) : 22;
const vaultContent = buildVaultKnownHostsContent(knownHosts, {
connectionHostname: hostname,
connectionPort,
// Prefer HostKeyAlias; else rewrite under the resolved HostName (with port).
hostKeyAlias,
resolvedHostName,
}).trimEnd();
if (!vaultContent) return "";
// Filter system pins for vault-covered hosts. Only when the vault pins THIS
// hop do we also strip system entries under HostName / HostKeyAlias aliases
// for the hop — otherwise an unrelated vault pin would wipe a trusted
// system entry for the current target and break strict stats probes.
const vaultSelectors = extractVaultHostSelectors(knownHosts);
const vaultPinsThisHop = vaultSelectors.some(
(selector) => (
selector.hostname === normalizeHostname(hostname)
&& selector.port === connectionPort
),
);
if (vaultPinsThisHop) {
const extraLookupNames = new Set([
normalizeHostname(hostname),
normalizeHostname(resolvedHostName),
normalizeHostname(hostKeyAlias),
normalizeHostname(lookupHostName),
]);
for (const name of extraLookupNames) {
if (!name) continue;
// HostKeyAlias / resolved names are stored as bare host tokens.
vaultSelectors.push({ hostname: name, port: 22 });
if (connectionPort !== 22) {
vaultSelectors.push({ hostname: name, port: connectionPort });
}
}
}
const chunks = [vaultContent];
const globals = Array.isArray(globalPaths)
? globalPaths
: resolveEffectiveGlobalKnownHostsPaths({
hostname,
port,
username,
platform,
programData,
homedir,
pathModule,
fs: fsApi,
execFileSyncFn,
sshCommand,
sshGOutput,
memo,
});
for (const filePath of globals) {
const filtered = filterKnownHostsContentExcludingVaultHosts(
readKnownHostsFileContent(fsApi, filePath),
vaultSelectors,
);
if (filtered) chunks.push(filtered);
}
const users = Array.isArray(userPaths)
? userPaths
: resolveEffectiveUserKnownHostsPaths({
hostname,
port,
username,
platform,
homedir,
pathModule,
fs: fsApi,
execFileSyncFn,
sshCommand,
sshGOutput,
memo,
});
for (const filePath of users) {
const filtered = filterKnownHostsContentExcludingVaultHosts(
readKnownHostsFileContent(fsApi, filePath),
vaultSelectors,
);
if (filtered) chunks.push(filtered);
}
return `${chunks.join("\n")}\n`;
};
// Back-compat name used by earlier call sites / tests.
const buildMergedGlobalKnownHostsContent = (opts = {}) =>
buildAuthoritativeKnownHostsContent(opts);
/**
* @param {object} opts
* @param {boolean} [opts.verifyHostKeys=true]
* @param {"et"|"mosh"} [opts.protocol="et"]
* @returns {"accept-new"|"ask"|"no"}
*/
const resolveExternalStrictHostKeyChecking = ({
verifyHostKeys = true,
protocol = "et",
} = {}) => {
if (verifyHostKeys === false) return "no";
// ET cannot answer OpenSSH's interactive host-key prompt (SSH_ASKPASS only
// covers passwords/passphrases). accept-new still rejects a changed key.
if (protocol === "et") return "accept-new";
// Force ask for Mosh so a user ssh_config StrictHostKeyChecking=no/off
// cannot disable Netcatty's verification setting.
return "ask";
};
/**
* Quote an OpenSSH option value when it contains whitespace or quotes so
* path-valued options are not split into multiple filenames.
*/
const quoteOpenSshOptionValue = (value) => {
const text = String(value ?? "");
if (!text) return text;
if (!/[\s"]/.test(text)) return text;
return `"${text.replace(/(["\\])/g, "\\$1")}"`;
};
/**
* Build OpenSSH -o style option strings (or bare KEY=VALUE for ET --ssh-option).
*
* @param {object} opts
* @param {string|null|undefined} opts.authoritativeKnownHostsPath
* Path to the vault-authoritative known_hosts snapshot (vault pins +
* filtered system entries). When set under verifyHostKeys=true, both
* UserKnownHostsFile and GlobalKnownHostsFile point here so no unfiltered
* system file remains.
* @param {string|null|undefined} opts.mergedGlobalKnownHostsPath
* Alias for authoritativeKnownHostsPath (back-compat).
* @param {string|null|undefined} opts.emptyKnownHostsPath
* Empty trust file used when verification is disabled.
* @param {boolean} [opts.verifyHostKeys=true]
* @param {"et"|"mosh"} [opts.protocol="et"]
* @param {"args"|"values"} [opts.style="values"]
* @param {(p: string) => string} [opts.normalizePath]
* @returns {string[]}
*/
const buildExternalHostKeySshOptions = ({
authoritativeKnownHostsPath,
mergedGlobalKnownHostsPath,
emptyKnownHostsPath,
// Back-compat alias used by earlier call sites / tests.
vaultKnownHostsPath,
verifyHostKeys = true,
protocol = "et",
style = "values",
normalizePath = (p) => p,
} = {}) => {
const values = [];
const normalize = (p) => {
if (typeof p !== "string" || !p.trim()) return "";
return normalizePath(p.trim());
};
if (verifyHostKeys === false) {
const emptyPath = normalize(emptyKnownHostsPath);
if (emptyPath) {
const quoted = quoteOpenSshOptionValue(emptyPath);
// Neutralize every trust source. StrictHostKeyChecking=no alone is not
// enough: OpenSSH still refuses password auth when a known_hosts pin
// mismatches the live key.
values.push(`UserKnownHostsFile=${quoted}`);
values.push(`GlobalKnownHostsFile=${quoted}`);
}
// Disable KnownHostsCommand so dynamic trust cannot reintroduce pins.
values.push("KnownHostsCommand=none");
values.push("StrictHostKeyChecking=no");
} else {
const trustPath = normalize(
authoritativeKnownHostsPath
|| mergedGlobalKnownHostsPath
|| vaultKnownHostsPath,
);
if (trustPath) {
const quoted = quoteOpenSshOptionValue(trustPath);
// Vault-authoritative snapshot for both slots so OpenSSH cannot fall
// back to an unfiltered system known_hosts that still pins a rotated key.
values.push(`UserKnownHostsFile=${quoted}`);
values.push(`GlobalKnownHostsFile=${quoted}`);
// KnownHostsCommand runs in addition to known_hosts files; disable it
// when enforcing vault authority so a dynamic command cannot return a
// rotated live key that bypasses the vault pin.
values.push("KnownHostsCommand=none");
}
const strict = resolveExternalStrictHostKeyChecking({ verifyHostKeys, protocol });
if (strict) {
values.push(`StrictHostKeyChecking=${strict}`);
}
}
if (style === "args") {
const args = [];
for (const value of values) {
args.push("-o", value);
}
return args;
}
return values;
};
/**
* SSH config Host-block lines (indented) for jump-host stanzas.
*
* Path-valued options (GlobalKnownHostsFile / UserKnownHostsFile) may need
* quoting and path normalization. Enum-valued options such as
* StrictHostKeyChecking=accept-new must stay literal — path-quoting helpers
* would resolve "accept-new" into a filesystem path.
*/
const buildExternalHostKeyConfigLines = ({
authoritativeKnownHostsPath,
mergedGlobalKnownHostsPath,
emptyKnownHostsPath,
vaultKnownHostsPath,
verifyHostKeys = true,
protocol = "et",
indent = " ",
normalizePath = (p) => p,
quotePath = (v) => quoteOpenSshOptionValue(v),
} = {}) => {
const values = buildExternalHostKeySshOptions({
authoritativeKnownHostsPath,
mergedGlobalKnownHostsPath,
emptyKnownHostsPath,
vaultKnownHostsPath,
verifyHostKeys,
protocol,
style: "values",
normalizePath,
});
return values.map((value) => {
const eq = value.indexOf("=");
if (eq <= 0) return `${indent}${value}`;
const key = value.slice(0, eq);
let raw = value.slice(eq + 1);
// Values may already be quoted by buildExternalHostKeySshOptions.
if (
(key === "GlobalKnownHostsFile" || key === "UserKnownHostsFile")
&& !(raw.startsWith('"') && raw.endsWith('"'))
) {
raw = quotePath(raw);
}
return `${indent}${key} ${raw}`;
});
};
module.exports = {
buildAuthoritativeKnownHostsContent,
buildExternalHostKeyConfigLines,
buildExternalHostKeySshOptions,
buildMergedGlobalKnownHostsContent,
buildVaultKnownHostsContent,
extractVaultHostSelectors,
filterKnownHostsContentExcludingVaultHosts,
formatVaultKnownHostLine,
getDefaultGlobalKnownHostsPaths,
getDefaultUserKnownHostsPaths,
openSshHostGlobMatches,
parseSshGKnownHostsPaths,
parseSshGScalar,
quoteOpenSshOptionValue,
resolveEffectiveGlobalKnownHostsPaths,
resolveEffectiveUserKnownHostsPaths,
resolveExternalStrictHostKeyChecking,
splitKnownHostsPathList,
vaultPinsConnectionHosts,
};