Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 142 additions & 4 deletions src/node/runtime/SSH2ConnectionPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import * as fs from "fs/promises";
import * as os from "os";
import * as path from "path";
import { createHmac } from "crypto";
import { spawn, type ChildProcess } from "child_process";
import { Duplex } from "stream";
import type { Client } from "ssh2";
Expand Down Expand Up @@ -138,6 +139,137 @@ const DEFAULT_IDENTITY_FILES = [
"~/.ssh/id_ed25519_sk",
"~/.ssh/id_dsa",
];

const KNOWN_HOSTS_PATH = path.join(os.homedir(), ".ssh", "known_hosts");

function matchesHashedKnownHost(pattern: string, host: string): boolean {
if (!pattern.startsWith("|1|")) {
return false;
}

const parts = pattern.split("|");
if (parts.length !== 5) {
return false;
}

const salt = Buffer.from(parts[3], "base64");
const expected = parts[4];
const actual = createHmac("sha1", salt).update(host).digest("base64");
return actual === expected;
}

const REGEX_SPECIAL_CHARS = new Set(["\\", "^", "$", "+", "?", ".", "(", ")", "|", "{", "}", "[", "]"]);

function wildcardPatternToRegex(pattern: string): RegExp {
let regex = "^";
for (const char of pattern) {
if (char === "*") {
regex += ".*";
continue;
}

if (char === "?") {
regex += ".";
continue;
}

if (REGEX_SPECIAL_CHARS.has(char)) {
regex += `\\${char}`;
continue;
}

regex += char;
}

regex += "$";
return new RegExp(regex);
}

function matchesKnownHostPattern(pattern: string, host: string): boolean {
if (pattern === "*" || pattern === host) {
return true;
}

if (pattern.includes("*") || pattern.includes("?")) {
return wildcardPatternToRegex(pattern).test(host);
}

return matchesHashedKnownHost(pattern, host);
}

function hostPatternListMatches(patterns: string[], host: string): boolean {
let hasPositiveMatch = false;

for (const rawPattern of patterns) {
const isNegated = rawPattern.startsWith("!");
const pattern = isNegated ? rawPattern.slice(1) : rawPattern;
if (!pattern) {
continue;
}

if (!matchesKnownHostPattern(pattern, host)) {
continue;
}

if (isNegated) {
return false;
}

hasPositiveMatch = true;
}

return hasPositiveMatch;
}

async function getKnownHostPublicKeys(host: string, port: number): Promise<Buffer[]> {
const knownHostsEntries = [host, `[${host}]:${port}`];

let content: string;
try {
content = await fs.readFile(KNOWN_HOSTS_PATH, "utf8");
} catch {
return [];
}

const keys: Buffer[] = [];
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) {
continue;
}

const fields = trimmed.split(/\s+/);
const marker = fields[0]?.startsWith("@") ? fields[0] : undefined;
if (marker === "@revoked" || marker === "@cert-authority") {
// The SSH2 verifier expects concrete host keys, not revoked keys or CA keys.
continue;
}

const hostFieldIndex = marker ? 1 : 0;
const keyFieldIndex = hostFieldIndex + 2;
const hostPatterns = fields[hostFieldIndex]?.split(",");
const keyBlob = fields[keyFieldIndex];
if (!hostPatterns?.length || !keyBlob) {
continue;
}

const matchesHost = knownHostsEntries.some((knownHost) =>
hostPatternListMatches(hostPatterns, knownHost)
);
if (!matchesHost) {
continue;
}

try {
keys.push(Buffer.from(keyBlob, "base64"));
} catch {
// Ignore malformed known_hosts entries.
}
}

return keys;
}

function expandLocalPath(value: string): string {
if (value === "~") {
return os.homedir();
Expand Down Expand Up @@ -569,6 +701,11 @@ export class SSH2ConnectionPool {
cleanupProxy();
});

const allowedHostKeys = await getKnownHostPublicKeys(
resolvedConfig.hostName,
resolvedConfig.port
);

await new Promise<void>((resolve, reject) => {
const onReady = () => {
cleanup();
Expand Down Expand Up @@ -607,10 +744,11 @@ export class SSH2ConnectionPool {
keepaliveInterval: 5000,
keepaliveCountMax: 2,
...(privateKey ? { privateKey } : {}),
// TODO(ethanndickson): Implement known_hosts support for SSH2
// and restore interactive host key verification once approvals
// can be persisted between connections.
hostVerifier: () => true,
// Enforce known_hosts host key pinning so SSH2 cannot silently
// accept attacker-controlled keys during transport negotiation.
hostVerifier: (presentedKey: Buffer) =>
allowedHostKeys.length > 0 &&
allowedHostKeys.some((knownHostKey) => knownHostKey.equals(presentedKey)),
};

client.connect(connectOptions);
Expand Down
Loading