Files
Netcatty/electron/bridges/sshAlgorithms.cjs
2026-06-05 05:54:23 +00:00

289 lines
10 KiB
JavaScript

const crypto = require("node:crypto");
const FIXED_DH_GROUP_BY_KEX = Object.freeze({
"diffie-hellman-group1-sha1": "modp2",
"diffie-hellman-group14-sha1": "modp14",
"diffie-hellman-group14-sha256": "modp14",
"diffie-hellman-group16-sha512": "modp16",
"diffie-hellman-group18-sha512": "modp18",
});
const CIPHER_SSL_NAME_BY_ALGORITHM = Object.freeze({
"chacha20-poly1305@openssh.com": "chacha20",
"aes128-gcm@openssh.com": "aes-128-gcm",
"aes256-gcm@openssh.com": "aes-256-gcm",
"aes128-gcm": "aes-128-gcm",
"aes256-gcm": "aes-256-gcm",
"aes128-ctr": "aes-128-ctr",
"aes192-ctr": "aes-192-ctr",
"aes256-ctr": "aes-256-ctr",
"aes128-cbc": "aes-128-cbc",
"aes192-cbc": "aes-192-cbc",
"aes256-cbc": "aes-256-cbc",
"3des-cbc": "des-ede3-cbc",
});
let _md5Supported = null;
let _supportedCiphers = null;
const dhGroupSupport = new Map();
// MODP groups that every SSH runtime we target supports, so we skip the
// feature-detection probe for them. Under Electron's BoringSSL, instantiating a
// fixed DH group object purely to test support is pathologically slow — the
// 8192-bit modp18 alone takes ~20s on first call, freezing the first connection
// of every app launch — yet the probe always succeeds. We only feature-detect
// groups a runtime might genuinely drop (e.g. BoringSSL removed the weak
// 1024-bit group1/modp2); those fail their probe instantly, so it stays cheap.
const ASSUMED_SUPPORTED_DH_GROUPS = new Set(["modp14", "modp16", "modp18"]);
// FIPS-enabled OpenSSL builds disable MD5. Feature-detect once so the legacy
// algorithm list can skip hmac-md5 on those builds; ssh2 validates exact
// algorithm lists strictly and would otherwise throw "Unsupported algorithm"
// before the SSH handshake even starts.
function md5Supported() {
if (_md5Supported === null) {
try { _md5Supported = crypto.getHashes().includes("md5"); }
catch { _md5Supported = false; }
}
return _md5Supported;
}
function fixedDhGroupSupported(groupName) {
if (ASSUMED_SUPPORTED_DH_GROUPS.has(groupName)) return true;
if (!dhGroupSupport.has(groupName)) {
try {
crypto.createDiffieHellmanGroup(groupName);
dhGroupSupport.set(groupName, true);
} catch {
dhGroupSupport.set(groupName, false);
}
}
return dhGroupSupport.get(groupName);
}
function filterSupportedFixedDhKex(kexAlgorithms) {
return kexAlgorithms.filter((kexName) => {
const groupName = FIXED_DH_GROUP_BY_KEX[kexName];
return !groupName || fixedDhGroupSupported(groupName);
});
}
function supportedCiphers() {
if (_supportedCiphers === null) {
try { _supportedCiphers = new Set(crypto.getCiphers()); }
catch { _supportedCiphers = new Set(); }
}
return _supportedCiphers;
}
function filterRuntimeUnsupportedCiphers(cipherAlgorithms) {
const ciphers = supportedCiphers();
return cipherAlgorithms.filter((algo) => {
const sslName = CIPHER_SSL_NAME_BY_ALGORITHM[algo];
return !sslName || ciphers.has(sslName);
});
}
function buildBaseAlgorithms() {
return {
cipher: filterRuntimeUnsupportedCiphers([
"aes128-gcm@openssh.com", "aes256-gcm@openssh.com",
"aes128-ctr", "aes192-ctr", "aes256-ctr",
"chacha20-poly1305@openssh.com",
]),
kex: filterSupportedFixedDhKex([
"curve25519-sha256", "curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521",
"diffie-hellman-group14-sha256",
"diffie-hellman-group16-sha512", "diffie-hellman-group18-sha512",
"diffie-hellman-group-exchange-sha256",
]),
compress: ["none"],
};
}
function applyLegacyAlgorithms(algorithms) {
algorithms.kex.push(...filterSupportedFixedDhKex([
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"diffie-hellman-group-exchange-sha1",
]));
algorithms.cipher.push(
...filterRuntimeUnsupportedCiphers([
"aes128-cbc", "aes256-cbc", "3des-cbc",
]),
);
algorithms.serverHostKey = [
"ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
"rsa-sha2-512", "rsa-sha2-256",
"ssh-rsa", "ssh-dss",
];
}
function applyLegacyHmacAlgorithms(algorithms) {
// Legacy HMACs: required by very old servers (e.g. FreeBSD 6.1 OpenSSH
// ~2006, issue #807). Without hmac-sha1/md5 in the offered list, the
// handshake exchange-hash MAC never agrees and the host-key signature
// verification that depends on it fails with
// "Handshake failed: signature verification failed", which looks like
// a host-key problem but is really a MAC negotiation mismatch.
//
// hmac-md5 is only appended when the local OpenSSL build actually
// supports MD5. FIPS-enabled Node builds disable MD5 entirely, and
// ssh2 strictly validates exact algorithm lists. Listing an unavailable
// algorithm would throw "Unsupported algorithm" before any SSH
// negotiation, turning the legacy toggle into a hard failure for FIPS
// users. hmac-sha1 is allowed for HMAC even under FIPS 140-2 so it
// stays unconditionally.
// hmac-sha1-etm@openssh.com is in ssh2's default MAC set. Keep it so
// hosts that only accept EtM SHA-1 MACs don't regress to "no matching
// C->S MAC" when legacy mode replaces the default list.
algorithms.hmac = [
"hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256", "hmac-sha2-512",
"hmac-sha1-etm@openssh.com",
"hmac-sha1",
];
if (md5Supported()) {
algorithms.hmac.push("hmac-md5");
}
}
// Mirror of ssh2's DEFAULT_SERVER_HOST_KEY (see ssh2/lib/protocol/constants.js)
// minus every ecdsa-sha2-*. Used when skipEcdsaHostKey is on but legacy mode
// is off, so the field would otherwise be undefined and ssh2 would fall back
// to its built-in defaults (which still include ecdsa-sha2-*).
const MODERN_SERVER_HOST_KEY_WITHOUT_ECDSA = Object.freeze([
"ssh-ed25519",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
]);
function applyEcdsaHostKeySkip(algorithms) {
if (algorithms.serverHostKey) {
algorithms.serverHostKey = algorithms.serverHostKey.filter(
(algo) => !algo.startsWith("ecdsa-sha2-"),
);
} else {
algorithms.serverHostKey = [...MODERN_SERVER_HOST_KEY_WITHOUT_ECDSA];
}
}
// Categories the user can override via host.algorithms. Mirrors ssh2's
// algorithm-object shape (note ssh2 uses 'compress', not 'compression').
const OVERRIDABLE_CATEGORIES = Object.freeze([
"kex", "cipher", "hmac", "serverHostKey", "compress",
]);
function filterRuntimeUnsupportedHmac(list) {
if (md5Supported()) return list;
// FIPS-enabled Node disables MD5. ssh2's generateAlgorithmList rejects
// hmac-md5 / hmac-md5-96 against its SUPPORTED_MAC (filtered by
// canUseMAC at startup) and throws "Unsupported algorithm" before any
// SSH negotiation — drop those entries from a user override the same
// way applyLegacyHmacAlgorithms gates the legacy seed.
return list.filter((algo) => !algo.startsWith("hmac-md5"));
}
function applyAlgorithmOverrides(algorithms, overrides) {
if (!overrides || typeof overrides !== "object") return;
for (const key of OVERRIDABLE_CATEGORIES) {
const list = overrides[key];
if (Array.isArray(list) && list.length > 0) {
// Copy so caller mutation cannot leak back into the host config object.
const copy = list.slice();
let filtered;
if (key === "kex") {
// KEX needs the same runtime fixed-DH support filter the default
// builder applies — BoringSSL drops modp2 (the prime backing
// `diffie-hellman-group1-sha1`), and an override that re-introduces
// an unsupported group would make ssh2 throw "Unknown DH group"
// mid-handshake instead of failing fast.
filtered = filterSupportedFixedDhKex(copy);
} else if (key === "hmac") {
filtered = filterRuntimeUnsupportedHmac(copy);
} else if (key === "cipher") {
filtered = filterRuntimeUnsupportedCiphers(copy);
} else {
filtered = copy;
}
algorithms[key] = filtered;
}
}
}
/**
* Build SSH algorithm configuration.
* When legacyEnabled is true, legacy algorithms are appended to each list
* (lower priority than modern ones) for compatibility with older network equipment.
*
* @param {boolean} legacyEnabled
* @param {{
* skipEcdsaHostKey?: boolean,
* algorithmOverrides?: Partial<Record<"kex"|"cipher"|"hmac"|"serverHostKey"|"compress", string[]>>,
* }} [options]
* skipEcdsaHostKey: drop every ecdsa-sha2-* from the host-key advertisement.
* Useful when a server (e.g. old Huawei VRP) negotiates ECDSA but produces
* a signature that ssh2's strict RFC verification rejects — see #1027.
* algorithmOverrides: per-category replacement lists (advanced). When a
* category's array is non-empty, it fully replaces the negotiated list
* for that category. Applied BEFORE skipEcdsaHostKey.
*/
function buildAlgorithms(legacyEnabled, options = {}) {
const algorithms = buildBaseAlgorithms();
if (legacyEnabled) {
applyLegacyAlgorithms(algorithms);
applyLegacyHmacAlgorithms(algorithms);
}
// User overrides apply BEFORE the ECDSA kill switch so the latter remains
// unconditional ("never advertise ECDSA on this host").
applyAlgorithmOverrides(algorithms, options.algorithmOverrides);
if (options.skipEcdsaHostKey) {
applyEcdsaHostKeySkip(algorithms);
}
return algorithms;
}
/**
* Build SSH algorithm configuration for SFTP connections.
* When legacyEnabled is true, legacy algorithms are appended for older device compatibility.
*
* @param {boolean} legacyEnabled
* @param {{ skipEcdsaHostKey?: boolean }} [options]
*/
function buildSftpAlgorithms(legacyEnabled, options = {}) {
const algorithms = buildBaseAlgorithms();
if (legacyEnabled) {
applyLegacyAlgorithms(algorithms);
applyLegacyHmacAlgorithms(algorithms);
}
// User overrides apply BEFORE the ECDSA kill switch so the latter remains
// unconditional ("never advertise ECDSA on this host").
applyAlgorithmOverrides(algorithms, options.algorithmOverrides);
if (options.skipEcdsaHostKey) {
applyEcdsaHostKeySkip(algorithms);
}
return algorithms;
}
function _resetAlgorithmSupportCacheForTests() {
_md5Supported = null;
_supportedCiphers = null;
dhGroupSupport.clear();
}
module.exports = {
buildAlgorithms,
buildSftpAlgorithms,
_resetAlgorithmSupportCacheForTests,
};