Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 1723024860 chore: retrigger CI after unrelated TERM-SSHD flake
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 16:08:36 +00:00
Cursor Agentand陈大猫 17f89ca6ef fix(terminal): match live startup host to the SSH dial target
A restored pane can hydrate a saved host with the same id but a new
hostname, port, username, or protocol while startSSH still dials the
frozen snapshot. Require those dial fields to match before reading
hostRef's startup command. Serial stays on the boot snapshot.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 15:52:13 +00:00
Cursor Agentand陈大猫 08355fa5cb fix(terminal): keep hydrated serial startup commands out of auto-login
Serial preflight decides whether to wait for auto-login from the boot
snapshot, then schedules again after the port opens. Reading hostRef at
that second step could type a command hydrated during startSerialSession
into the saved username/password exchange. Live startup-host lookup now
stays on the SSH restore path, including legacy hosts with no protocol.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 15:44:20 +00:00
Cursor Agentand陈大猫 f1d135f5af fix(terminal): run the live host startup command after restore
Restored SSH boot closes over the pre-hydration host and only restarts
when host.id changes, so scheduleStartupCommand missed a startup command
that landed on hostRef during the dial. Read that command at attach time.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-24 15:35:58 +00:00
15 changed files with 100 additions and 1271 deletions

No files matched your search

-10
View File
@@ -47,16 +47,6 @@ test("effectiveDefaultAlgorithms (modern) includes chacha20-poly1305", () => {
assert.ok(result.cipher.includes("chacha20-poly1305@openssh.com"));
});
test("effectiveDefaultAlgorithms offers OpenSSH host certificates before plain host keys", () => {
const cert = "ssh-ed25519-cert-v01@openssh.com";
const modern = effectiveDefaultAlgorithms(false);
assert.ok(modern.serverHostKey.includes(cert));
assert.ok(modern.serverHostKey.indexOf(cert) < modern.serverHostKey.indexOf("ssh-ed25519"));
const legacy = effectiveDefaultAlgorithms(true);
assert.ok(legacy.serverHostKey.includes(cert));
assert.ok(legacy.serverHostKey.includes("ssh-dss"));
});
test("effectiveDefaultAlgorithms (legacy) appends sha1 KEX, CBC, and ssh-dss", () => {
const modern = effectiveDefaultAlgorithms(false);
const legacy = effectiveDefaultAlgorithms(true);
-14
View File
@@ -77,13 +77,6 @@ export const SUPPORTED_HMAC_ALGORITHMS: readonly string[] = [
];
export const SUPPORTED_SERVER_HOST_KEY_ALGORITHMS: readonly string[] = [
"ssh-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
@@ -155,13 +148,6 @@ const MODERN_DEFAULT_ALGORITHMS: Readonly<Record<SSHAlgorithmCategory, readonly
"hmac-sha1",
],
serverHostKey: [
"ssh-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
-3
View File
@@ -195,9 +195,6 @@ module.exports = {
'node_modules/@cursor/sdk-*/**/*',
'node_modules/sqlite3/**/*',
'node_modules/@modelcontextprotocol/sdk/**/*',
// External MCP scripts run from asarUnpack via ELECTRON_RUN_AS_NODE and
// must resolve re2js from a real directory (issue #3910).
'node_modules/re2js/**/*',
'lib/**/*.cjs',
'lib/**/*.json',
'node_modules/zod/**/*',
+5 -8
View File
@@ -61,13 +61,10 @@ const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
// PowerShell's `PS C:\...>` (handled by POWERSHELL_PROMPT_PATTERN first).
const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
// Default login prompts, kept narrow so custom / fish / starship prompts do
// not flip a Windows DefaultShell soft hint:
// - bash/zsh `user@host[:path][#$]` (colon before the path; `root@host ~#` stays rejected)
// - RHEL-family `[\u@\h \W]\$` -> `[user@host path][#$]`, or `[user@host][#$]`
// with no path. `\W` may contain whitespace or `]` (`[user@host My [Project]]$`);
// the final `]` before `$`/`#` is the delimiter.
const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s\]:]+(?:\s+[^\n\r]+)?\])[#$]$/;
// Classic `user@host:...$` / `user@host:...#` login prompt (bash/zsh in WSL,
// remote Linux, etc.). Intentionally narrow so custom / fish prompts do not
// flip a Windows DefaultShell soft hint.
const POSIX_PROMPT_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/;
function isDefaultPowerShellPromptLine(line) {
return POWERSHELL_PROMPT_PATTERN.test(String(line || ""));
@@ -152,7 +149,7 @@ function trackSessionIdlePrompt(session, chunk) {
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
// still ends with it. The cached prompt is updated only when
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
// `user@host[:path][#$]`, or RHEL `[user@host path][#$]`); a remote shell switch into another shell, an
// or `user@host[:path][#$]`); a remote shell switch into another shell, an
// oh-my-posh / starship / custom PS1, or any unrecognized prompt would
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
// would then keep using to coerce future commands into a PowerShell
-65
View File
@@ -133,50 +133,14 @@ test("isDefaultCmdPromptLine matches drive-letter cmd prompts only", () => {
test("isDefaultPosixPromptLine matches classic user@host prompts", () => {
assert.equal(isDefaultPosixPromptLine("alice@host:~$"), true);
assert.equal(isDefaultPosixPromptLine("user@host:~$"), true);
assert.equal(isDefaultPosixPromptLine("alice@wsl:/mnt/c$"), true);
assert.equal(isDefaultPosixPromptLine("root@box:/#"), true);
assert.equal(isDefaultPosixPromptLine("[opc@instance-20260905-1821 ~]$"), true);
assert.equal(isDefaultPosixPromptLine("[opc@instance-20260905-1821 ~]$ "), true);
assert.equal(isDefaultPosixPromptLine("[root@host ~]#"), true);
assert.equal(isDefaultPosixPromptLine("[user@host]$"), true);
assert.equal(isDefaultPosixPromptLine("[user@host My Project]$"), true);
assert.equal(isDefaultPosixPromptLine("[user@host My Project]$ "), true);
assert.equal(isDefaultPosixPromptLine("[user@host My [Project]]$"), true);
assert.equal(isDefaultPosixPromptLine("[user@host My [Project]]$ "), true);
assert.equal(isDefaultPosixPromptLine("root@host ~#"), false);
assert.equal(isDefaultPosixPromptLine("PS C:\\Users\\alice>"), false);
assert.equal(isDefaultPosixPromptLine("C:\\Users\\alice>"), false);
assert.equal(isDefaultPosixPromptLine(""), false);
});
test("extracts a trailing RHEL-family bracketed idle prompt", () => {
// Oracle Linux / RHEL / CentOS / Fedora default PS1 is `[\u@\h \W]\$ `.
// Trailing space must round-trip, same as the PowerShell prompt capture.
assert.equal(
extractTrailingIdlePrompt("[opc@instance-20260905-1821 ~]$ "),
"[opc@instance-20260905-1821 ~]$ ",
);
assert.equal(
extractTrailingIdlePrompt("Last login: Fri Oct 24\r\n[root@host ~]#"),
"[root@host ~]#",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "),
"user@host:~$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n[user@host My Project]$ "),
"[user@host My Project]$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n[user@host My [Project]]$ "),
"[user@host My [Project]]$ ",
);
// Unbracketed space form is still not a recognized default prompt.
assert.equal(extractTrailingIdlePrompt("welcome\r\nroot@host ~#"), "");
});
test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
@@ -710,35 +674,6 @@ test("getFreshIdlePrompt returns empty string when the session has no cached pro
);
});
test("trackSessionIdlePrompt and getFreshIdlePrompt accept an Oracle/RHEL bracketed bash prompt", () => {
const session = {};
trackSessionIdlePrompt(session, "Last login...\r\n[opc@instance-20260905-1821 ~]$ ");
assert.equal(session.lastIdlePrompt, "[opc@instance-20260905-1821 ~]$ ");
assert.equal(getFreshIdlePrompt(session), "[opc@instance-20260905-1821 ~]$ ");
const rootSession = {};
trackSessionIdlePrompt(rootSession, "[root@host ~]#");
assert.equal(getFreshIdlePrompt(rootSession), "[root@host ~]#");
const classic = {};
trackSessionIdlePrompt(classic, "user@host:~$ ");
assert.equal(getFreshIdlePrompt(classic), "user@host:~$ ");
const unbracketed = {};
assert.equal(trackSessionIdlePrompt(unbracketed, "root@host ~#"), "");
assert.equal(getFreshIdlePrompt(unbracketed), "");
const spacedPath = {};
trackSessionIdlePrompt(spacedPath, "Last login...\r\n[user@host My Project]$ ");
assert.equal(spacedPath.lastIdlePrompt, "[user@host My Project]$ ");
assert.equal(getFreshIdlePrompt(spacedPath), "[user@host My Project]$ ");
const bracketedPath = {};
trackSessionIdlePrompt(bracketedPath, "Last login...\r\n[user@host My [Project]]$ ");
assert.equal(bracketedPath.lastIdlePrompt, "[user@host My [Project]]$ ");
assert.equal(getFreshIdlePrompt(bracketedPath), "[user@host My [Project]]$ ");
});
test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PTY-like flow", () => {
// (1) Remote PowerShell prompt arrives — lastIdlePrompt is captured.
const session = {};
@@ -1,289 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
DEFAULT_SERVER_HOST_KEY,
SUPPORTED_SERVER_HOST_KEY,
} = require("ssh2/lib/protocol/constants.js");
const {
parseDERKey,
parseKey,
} = require("ssh2/lib/protocol/keyParser.js");
const {
hostKeyBlobTypeAccepted,
hostKeySignatureTypeAccepted,
} = require("ssh2/lib/protocol/kex.js");
// Manual live check (optional; needs network and real Instabox credentials):
// ssh -vv -p 2222 USER@ssh.instabox.sh
// Instabox advertises only ssh-ed25519-cert-v01@openssh.com and signs the
// exchange hash as ssh-ed25519. After this patch, host-key negotiation should
// succeed; authentication still depends on the real certificate credentials.
const HOST_CERT = "ssh-ed25519-cert-v01@openssh.com";
const HOST_CERT_ALGORITHMS = [
HOST_CERT,
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
];
function sshKeygenSkipReason(error) {
if (error && error.code === "ENOENT")
return "ssh-keygen is not installed";
return false;
}
function detectSshKeygenSkip() {
try {
execFileSync("ssh-keygen", ["-h"], { stdio: "ignore" });
return false;
} catch (error) {
return sshKeygenSkipReason(error);
}
}
const sshKeygenSkip = detectSshKeygenSkip();
function mintEd25519HostCert(dir) {
const ca = path.join(dir, "ca");
const host = path.join(dir, "host");
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", ca]);
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", host]);
execFileSync("ssh-keygen", [
"-q",
"-s",
ca,
"-I",
"netcatty-hostcert",
"-h",
"-n",
"127.0.0.1",
"-V",
"+52w",
`${host}.pub`,
]);
const certLine = fs.readFileSync(`${host}-cert.pub`, "utf8").trim();
const [type, b64] = certLine.split(/\s+/);
const privateKey = parseKey(fs.readFileSync(host));
return {
type,
certBlob: Buffer.from(b64, "base64"),
privateKeyPem: fs.readFileSync(host),
privateKey,
};
}
test("ssh2 default and supported host key lists offer OpenSSH host certificates", () => {
for (const algo of HOST_CERT_ALGORITHMS) {
assert.equal(DEFAULT_SERVER_HOST_KEY.includes(algo), true, `${algo} missing from defaults`);
assert.equal(SUPPORTED_SERVER_HOST_KEY.includes(algo), true, `${algo} missing from supported`);
}
assert.ok(
DEFAULT_SERVER_HOST_KEY.indexOf(HOST_CERT) < DEFAULT_SERVER_HOST_KEY.indexOf("ssh-ed25519"),
"host certificates should be offered before the matching plain key",
);
assert.equal(DEFAULT_SERVER_HOST_KEY.includes("ssh-ed25519"), true);
assert.equal(DEFAULT_SERVER_HOST_KEY.includes("ssh-rsa"), true);
assert.equal(
SUPPORTED_SERVER_HOST_KEY.includes("sk-ssh-ed25519-cert-v01@openssh.com"),
true,
);
assert.equal(
SUPPORTED_SERVER_HOST_KEY.includes("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com"),
true,
);
});
test("missing ssh-keygen skips host-certificate minting instead of failing", () => {
const missing = Object.assign(new Error("spawn ssh-keygen ENOENT"), { code: "ENOENT" });
assert.equal(sshKeygenSkipReason(missing), "ssh-keygen is not installed");
const present = Object.assign(new Error("usage"), { status: 1 });
assert.equal(sshKeygenSkipReason(present), false);
assert.equal(sshKeygenSkipReason(null), false);
});
test("parseDERKey reads an OpenSSH ed25519 host certificate the way KEX does", { skip: sshKeygenSkip }, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-hostcert-"));
try {
const minted = mintEd25519HostCert(dir);
assert.equal(minted.type, HOST_CERT);
assert.equal(minted.privateKey instanceof Error, false, minted.privateKey.message);
const typeLen = minted.certBlob.readUInt32BE(0);
const type = minted.certBlob.subarray(4, 4 + typeLen).toString();
const keyMaterial = minted.certBlob.subarray(4 + typeLen);
const parsed = parseDERKey(keyMaterial, type);
assert.equal(parsed instanceof Error, false, parsed instanceof Error ? parsed.message : "");
assert.equal(parsed.type, HOST_CERT);
const message = Buffer.from("kex-exchange-hash");
const signature = minted.privateKey.sign(message);
assert.equal(signature instanceof Error, false, signature instanceof Error ? signature.message : "");
assert.equal(parsed.verify(message, signature), true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("host key signature type accepts the underlying algorithm for host certificates", () => {
assert.equal(typeof hostKeySignatureTypeAccepted, "function");
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, "ssh-ed25519"), true);
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, HOST_CERT), true);
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, "ssh-rsa"), false);
assert.equal(
hostKeySignatureTypeAccepted("ecdsa-sha2-nistp256-cert-v01@openssh.com", "ecdsa-sha2-nistp256"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "rsa-sha2-256"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "rsa-sha2-512"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v00@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-512"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-256"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "rsa-sha2-256"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "rsa-sha2-512"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "ssh-rsa"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v00@openssh.com", "ssh-rsa"),
true,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa-cert-v01@openssh.com"),
true,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa-cert-v01@openssh.com"),
true,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(hostKeyBlobTypeAccepted("rsa-sha2-256", "ssh-rsa"), true);
assert.equal(hostKeyBlobTypeAccepted("rsa-sha2-512", "ssh-rsa"), true);
assert.equal(hostKeySignatureTypeAccepted("ssh-ed25519", "ssh-ed25519"), true);
assert.equal(hostKeySignatureTypeAccepted("ssh-ed25519", HOST_CERT), false);
assert.equal(hostKeySignatureTypeAccepted("rsa-sha2-256", "ssh-rsa"), false);
assert.equal(hostKeySignatureTypeAccepted("rsa-sha2-512", "rsa-sha2-256"), false);
});
function loadSsh2WithCertHostKey(pem, certBlob) {
// ssh2.Server only accepts PEM text and then advertises privateKey.type.
// Reload it after wrapping parseKey so this one key is presented as an
// OpenSSH host certificate while signing still uses the embedded key.
const keyParser = require("ssh2/lib/protocol/keyParser.js");
const originalParseKey = keyParser.parseKey;
keyParser.parseKey = function parseCertHostKey(data, passphrase) {
const parsed = originalParseKey(data, passphrase);
if (!(parsed instanceof Error) && data === pem) {
parsed.type = HOST_CERT;
parsed.getPublicSSH = () => certBlob;
}
return parsed;
};
const reloadIds = ["ssh2/lib/server.js", "ssh2/lib/index.js", "ssh2"];
const resolved = reloadIds.map((id) => require.resolve(id));
const previous = resolved.map((filename) => require.cache[filename]);
for (const filename of resolved) delete require.cache[filename];
const ssh2 = require("ssh2");
for (let i = 0; i < resolved.length; i++) {
if (previous[i]) require.cache[resolved[i]] = previous[i];
else delete require.cache[resolved[i]];
}
keyParser.parseKey = originalParseKey;
return ssh2;
}
test("ssh2 client completes KEX against a cert-only ed25519 host key", { skip: sshKeygenSkip }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-hostcert-kex-"));
const minted = mintEd25519HostCert(dir);
assert.equal(minted.privateKey instanceof Error, false, minted.privateKey.message);
const { Client, Server } = loadSsh2WithCertHostKey(minted.privateKeyPem, minted.certBlob);
const server = new Server({ hostKeys: [minted.privateKeyPem] }, (client) => {
client.on("error", () => {});
client.on("authentication", (ctx) => ctx.reject());
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
try {
const authError = await new Promise((resolve, reject) => {
const client = new Client();
const timer = setTimeout(() => {
client.end();
reject(new Error("timed out waiting for KEX/auth"));
}, 10000);
client.on("ready", () => {
clearTimeout(timer);
client.end();
reject(new Error("authentication unexpectedly succeeded"));
});
client.on("error", (err) => {
clearTimeout(timer);
client.end();
resolve(err);
});
client.connect({
host: "127.0.0.1",
port: server.address().port,
username: "netcatty",
password: "not-the-password",
readyTimeout: 8000,
});
});
assert.match(authError.message, /authentication/i);
assert.doesNotMatch(
authError.message,
/Handshake failed|no matching host key|Wrong signature type|Unsupported OpenSSH|signature verification failed|Host key does not match/,
);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
-88
View File
@@ -2,12 +2,10 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createHash, generateKeyPairSync, sign } = require("node:crypto");
const {
parseKey,
getOpenSSHCertSignatureAlgorithm,
} = require("ssh2/lib/protocol/keyParser.js");
const { convertSignature } = require("ssh2/lib/protocol/utils.js");
function buildSkEd25519PublicLine() {
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
@@ -148,89 +146,3 @@ test("ssh2 agent protocol preserves SK signature framing, flags and counter", as
}
}
});
function sshString(value) {
const body = Buffer.isBuffer(value) ? value : Buffer.from(value);
const out = Buffer.alloc(4 + body.length);
out.writeUInt32BE(body.length, 0);
body.copy(out, 4);
return out;
}
function opensshPublicLine(typeName, fields) {
const blob = Buffer.concat([
sshString(typeName),
...fields.map((field) => sshString(field)),
]);
return `${typeName} ${blob.toString("base64")} netcatty@sk`;
}
function skEcdsaSignature(rawSig, flags, counter) {
const out = Buffer.alloc(4 + rawSig.length + 5);
out.writeUInt32BE(rawSig.length, 0);
rawSig.copy(out, 4);
const pos = 4 + rawSig.length;
out[pos] = flags;
out.writeUInt32BE(counter, pos + 1);
return out;
}
test("ssh2 verifies an SK ECDSA certificate with the same P-256 signature as the plain key", () => {
const { publicKey, privateKey } = generateKeyPairSync("ec", {
namedCurve: "prime256v1",
});
const jwk = publicKey.export({ format: "jwk" });
const point = Buffer.concat([
Buffer.from([0x04]),
Buffer.from(jwk.x, "base64url"),
Buffer.from(jwk.y, "base64url"),
]);
const application = "ssh:";
const plain = parseKey(opensshPublicLine("sk-ecdsa-sha2-nistp256@openssh.com", [
"nistp256",
point,
application,
]));
const cert = parseKey(opensshPublicLine("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", [
Buffer.alloc(16, 3),
"nistp256",
point,
application,
]));
assert.equal(plain instanceof Error, false, plain instanceof Error ? plain.message : "");
assert.equal(cert instanceof Error, false, cert instanceof Error ? cert.message : "");
assert.equal(cert.type, "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com");
assert.equal(cert.getApplication(), application);
const message = Buffer.from("kex-exchange-hash");
const flags = 0x01;
const counter = 0x12345678;
const appHash = createHash("sha256").update(application).digest();
const dataHash = createHash("sha256").update(message).digest();
const signedData = Buffer.alloc(appHash.length + 1 + 4 + dataHash.length);
let offset = 0;
appHash.copy(signedData, offset);
offset += appHash.length;
signedData[offset++] = flags;
signedData.writeUInt32BE(counter, offset);
offset += 4;
dataHash.copy(signedData, offset);
const der = sign("sha256", signedData, privateKey);
const rawSig = convertSignature(der, "ecdsa-sha2-nistp256");
assert.ok(Buffer.isBuffer(rawSig));
const signature = skEcdsaSignature(rawSig, flags, counter);
const plainResult = plain.verify(message, signature);
assert.equal(
plainResult,
true,
plainResult instanceof Error ? plainResult.message : String(plainResult),
);
const certResult = cert.verify(message, signature);
assert.equal(
certResult,
true,
certResult instanceof Error ? certResult.message : String(certResult),
);
});
+3 -16
View File
@@ -114,13 +114,6 @@ function applyLegacyAlgorithms(algorithms) {
]),
);
algorithms.serverHostKey = [
"ssh-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
"rsa-sha2-512", "rsa-sha2-256",
"ssh-rsa", "ssh-dss",
@@ -157,16 +150,10 @@ function applyLegacyHmacAlgorithms(algorithms) {
}
// Mirror of ssh2's DEFAULT_SERVER_HOST_KEY (see ssh2/lib/protocol/constants.js)
// minus every ecdsa-sha2-* (plain keys and host certificates). 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-*). Non-ECDSA host certificates stay so a cert-only
// bastion can still be reached.
// 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-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519",
"rsa-sha2-512",
"rsa-sha2-256",
-20
View File
@@ -269,19 +269,6 @@ for (const [label, buildAlgorithms] of [
["SSH", sshBridge.buildAlgorithms],
["SFTP", sftpBridge.buildSftpAlgorithms],
]) {
test(`${label} legacy serverHostKey offers OpenSSH host certificates before plain keys`, () => {
withAlgorithmRuntime({}, () => {
const keys = buildAlgorithms(true).serverHostKey;
assert.ok(keys.includes("ssh-ed25519-cert-v01@openssh.com"));
assert.ok(keys.indexOf("ssh-ed25519-cert-v01@openssh.com") < keys.indexOf("ssh-ed25519"));
assert.ok(keys.includes("ecdsa-sha2-nistp256-cert-v01@openssh.com"));
assert.ok(keys.includes("rsa-sha2-512-cert-v01@openssh.com"));
assert.ok(keys.includes("rsa-sha2-256-cert-v01@openssh.com"));
assert.ok(keys.includes("ssh-rsa-cert-v01@openssh.com"));
assert.ok(keys.includes("ssh-dss"));
});
});
test(`${label} skipEcdsaHostKey removes every ecdsa-sha2-* from serverHostKey (legacy on)`, () => {
withAlgorithmRuntime({}, () => {
const algorithms = buildAlgorithms(true, { skipEcdsaHostKey: true });
@@ -293,9 +280,6 @@ for (const [label, buildAlgorithms] of [
assert.ok(algorithms.serverHostKey.includes("ssh-rsa"));
assert.ok(algorithms.serverHostKey.includes("ssh-dss"));
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519"));
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-512-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("ssh-rsa-cert-v01@openssh.com"));
});
});
@@ -309,10 +293,6 @@ for (const [label, buildAlgorithms] of [
for (const algo of algorithms.serverHostKey) {
assert.ok(!algo.startsWith("ecdsa-sha2-"), `${algo} should be filtered out`);
}
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-512-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-256-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("ssh-rsa-cert-v01@openssh.com"));
});
});
+1 -26
View File
@@ -73,11 +73,6 @@ function normalizePassword(password) {
return typeof password === "string" ? password : "";
}
// Bare CR/LF used to wake a silent serial console before "login:" appears.
function isAutoLoginWakeInput(data) {
return typeof data === "string" && /^[\r\n]+$/.test(data);
}
function createTelnetAutoLogin(options = {}) {
const username = normalizeUsername(options.username);
const password = normalizePassword(options.password);
@@ -212,26 +207,7 @@ function createTelnetAutoLogin(options = {}) {
notifyIncomplete();
}
},
// Silent serial consoles need a bare CR/LF to wake getty before "login:"
// appears. While the detector is still active that wake is not a
// takeover: keep autofill armed, leave the caller's write on the port,
// and do not consume the one-shot continue-prompt response. A later
// "Press RETURN to continue" must still be answered. Once the detector
// is disabled (expiry, completion, or a prior cancel), the same Enter
// notifies like any other user input so callers can drop a deferred
// startup command. Any other user input still cancels.
handleUserInput(data) {
const wakeOnly = isAutoLoginWakeInput(data);
if (
wakeOnly
&& !disabled
&& !sentUsername
&& !sentPassword
&& !isUsernamePrompt(tail)
&& !isPasswordPrompt(tail)
) {
return;
}
handleUserInput() {
disabled = true;
clearExpiryTimer();
notifyUserInput();
@@ -248,7 +224,6 @@ function createTelnetAutoLogin(options = {}) {
module.exports = {
createTelnetAutoLogin,
isAutoLoginWakeInput,
isCommandPrompt,
isContinuePrompt,
isPasswordPrompt,
-149
View File
@@ -224,155 +224,6 @@ test("telnet auto-login stops when the user starts typing manually", () => {
assert.equal(cancelCount, 1);
});
test("telnet auto-login stays armed when a wake-only enter arrives before any login prompt", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
// Silent serial consoles need a bare CR to wake getty before "login:" appears.
autoLogin.handleUserInput("\r");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login treats a bare newline as a wake and still autofills", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleUserInput("\n");
autoLogin.handleUserInput("\r\n");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["admin\r", "secret\r"]);
});
test("telnet auto-login notifies when a wake enter arrives after expiry", () => {
let cancelCount = 0;
let timerFn = null;
const writes = [];
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
timeoutMs: 60_000,
onUserInput: () => { cancelCount += 1; },
setTimeout: (fn) => {
timerFn = fn;
return { unref() {} };
},
clearTimeout: () => {},
});
// Quiet device: the 60s window elapses with no prompt, which disables the
// detector without a user-input notification. Serial startup still has its
// 65s fallback armed until this cancellation arrives.
timerFn();
assert.equal(cancelCount, 0);
autoLogin.handleUserInput("\r");
assert.equal(cancelCount, 1);
autoLogin.handleText("Press RETURN to continue");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login still answers a continue prompt after a blind wake enter", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
// The user's Enter woke a silent console. It must not consume the
// detector's one-shot response when the device then asks to continue.
autoLogin.handleUserInput("\r");
autoLogin.handleText("Press RETURN to continue");
autoLogin.handleText("\r\nlogin: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 0);
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
});
test("telnet auto-login still cancels when a normal character arrives before login", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleUserInput("x");
autoLogin.handleText("login: ");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login still cancels an enter after credentials have started", () => {
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
onUserInput: () => { cancelCount += 1; },
});
autoLogin.handleText("login: ");
autoLogin.handleUserInput("\r");
autoLogin.handleText("\r\nPassword: ");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, ["admin\r"]);
});
test("telnet auto-login still cancels an enter when a login prompt is already in the tail", () => {
let clock = 0;
const writes = [];
let cancelCount = 0;
const autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => writes.push(data),
now: () => clock,
timeoutMs: 60_000,
onUserInput: () => { cancelCount += 1; },
});
// After the window, the prompt is recorded but credentials are not sent.
clock = 60_001;
autoLogin.handleText("login: ");
autoLogin.handleUserInput("\r");
assert.equal(cancelCount, 1);
assert.deepEqual(writes, []);
});
test("telnet auto-login avoids common non-prompt login text", () => {
const writes = [];
const autoLogin = createTelnetAutoLogin({
+7 -27
View File
@@ -63,7 +63,7 @@ const { discoverShells } = require("./shellDiscovery.cjs");
const { isWindowsAppExecutionAliasPath } = require("../../lib/localShell.cjs");
const moshHandshake = require("./moshHandshake.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const { createTelnetAutoLogin, isAutoLoginWakeInput } = require("./telnetAutoLogin.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
const telnetProtocol = require("./telnetProtocol.cjs");
const { createPtyOutputBuffer } = require("./ptyOutputBuffer.cjs");
const { enableTcpNoDelay } = require("./tcpNoDelay.cjs");
@@ -1713,9 +1713,8 @@ function writeToSessionNow(payload, data, logRewrite = payload.logRewrite) {
(session.type === 'telnet-native' || session.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
&& !payload.autoLoginInputHandled
) {
session.autoLogin?.handleUserInput(data);
session.autoLogin?.handleUserInput();
}
// Encode keystrokes with the SAME charset the output path decodes with so
@@ -1806,8 +1805,6 @@ function writeToSessionWithInterception(
paste = null,
index = 0,
) {
// Stays the original payload until ingress classification marks this write.
let sessionWritePayload = payload;
const writeWithReceipt = (nextData) => {
if (paste && !paste.active) return;
const current = sessions.get(payload.sessionId);
@@ -1820,7 +1817,7 @@ function writeToSessionWithInterception(
paste.finish(index, true);
return;
}
const written = writeToSessionNow(sessionWritePayload, nextData, logRewrite);
const written = writeToSessionNow(payload, nextData, logRewrite);
if (paste) paste.finish(written ? index : undefined);
};
const bypass = payload?.sensitive === true || isTerminalReportSequence(data);
@@ -1833,24 +1830,20 @@ function writeToSessionWithInterception(
writeWithReceipt(data);
return;
}
// Classify auto-login at input ingress, before the asynchronous
// Cancel the auto-login detector at input ingress, before the asynchronous
// interception pipeline: a slow interceptor would otherwise leave the
// detector armed while the user's keystrokes are queued, letting a login
// prompt that arrives during that wait trigger a saved-credential
// transmission after the user has already taken over. Same guard
// conditions as writeToSessionNow. Wake-only CR/LF is not idempotent once
// credentials start, so mark this write and do not classify those bytes
// again when they reach writeToSessionNow. That preserved classification
// describes the queued bytes only while they stay wake-only; writeIfCurrent
// classifies an interceptor rewrite that is no longer wake-only.
// conditions as writeToSessionNow; handleUserInput is idempotent, so the
// later call there stays a no-op.
if (
expectedSession
&& (expectedSession.type === 'telnet-native' || expectedSession.type === 'serial')
&& !payload.automated
&& !isTerminalReportSequence(data)
) {
expectedSession.autoLogin?.handleUserInput(data);
sessionWritePayload = { ...payload, autoLoginInputHandled: true };
expectedSession.autoLogin?.handleUserInput();
}
const writeIfCurrent = (nextData) => {
const current = sessions.get(payload.sessionId);
@@ -1858,19 +1851,6 @@ function writeToSessionWithInterception(
paste?.finish();
return;
}
// An Enter-to-command macro can turn the wake CR/LF classified above
// into ordinary text. Classify that transformed value before it is
// written. autoLoginInputHandled stays set so writeToSessionNow does not
// classify the same write again. A rewrite that is still only CR/LF keeps
// the ingress classification: those bytes are not idempotent once
// credentials have started.
if (
sessionWritePayload.autoLoginInputHandled
&& !isAutoLoginWakeInput(nextData)
&& !isTerminalReportSequence(nextData)
) {
current.autoLogin?.handleUserInput(nextData);
}
writeWithReceipt(nextData);
};
const write = async () => {
@@ -1,32 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const terminalBridge = require("./terminalBridge.cjs");
const { createTelnetAutoLogin } = require("./telnetAutoLogin.cjs");
function createSerialAutoLoginSession() {
const writes = [];
const events = [];
const session = {
type: "serial",
protocol: "serial",
encoding: "utf-8",
serialPort: {
write: (data) => {
writes.push(typeof data === "string" ? data : Buffer.from(data).toString());
return true;
},
},
};
session.autoLogin = createTelnetAutoLogin({
username: "admin",
password: "secret",
write: (data) => {
session.serialPort.write(data);
},
onUserInput: () => { events.push("cancelled"); },
});
return { session, writes, events };
}
test("serial auto-login cancels on interactive user input but not automated writes", () => {
let userInputs = 0;
@@ -87,221 +61,6 @@ test("serial line-mode buffered input notification cancels auto-login", () => {
}
});
test("serial wake-only enter keeps auto-login armed and still reaches the port", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\r"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\r", "admin\r", "secret\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial character input before login still cancels auto-login", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "x" });
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["x"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial wake-only enter stays armed when an input interceptor is pending", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorResolved = false;
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (sessionId, data) => {
await new Promise((resolve) => setTimeout(resolve, 20));
interceptorResolved = true;
return data;
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\n" });
assert.deepEqual(events, []);
assert.equal(interceptorResolved, false);
await new Promise((resolve) => setTimeout(resolve, 40));
assert.equal(interceptorResolved, true);
assert.deepEqual(writes, ["\n"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["\n", "admin\r", "secret\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial wake enter is not cancelled when login arrives during interceptor delay", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorStarted = false;
let releaseInterceptor = () => {};
const interceptorGate = new Promise((resolve) => {
releaseInterceptor = resolve;
});
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
interceptorStarted = true;
await new Promise((resolve) => setTimeout(resolve, 30));
await interceptorGate;
return data;
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
assert.equal(interceptorStarted, true);
// Prompt arrives while the wake byte is still queued in the interceptor.
session.autoLogin.handleText("login: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r"]);
releaseInterceptor();
const deadline = Date.now() + 500;
while (writes.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r"]);
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r", "secret\r"]);
} finally {
releaseInterceptor();
terminalBridge.cleanupAllSessions();
}
});
test("serial interceptor that rewrites a wake enter into text cancels auto-login", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let interceptorStarted = false;
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
interceptorStarted = true;
assert.equal(data, "\r");
await new Promise((resolve) => setTimeout(resolve, 20));
// Enter-to-command macro: the bare wake CR is no longer what is written.
return "uptime\r";
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
assert.equal(interceptorStarted, true);
// Ingress still sees a wake, so autofill stays armed until the rewrite lands.
assert.deepEqual(events, []);
assert.deepEqual(writes, []);
const deadline = Date.now() + 500;
while (writes.length < 1 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["uptime\r"]);
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, ["uptime\r"]);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial interceptor that rewrites a wake enter into another wake still autofills", async () => {
const { session, writes, events } = createSerialAutoLoginSession();
let releaseInterceptor = () => {};
const interceptorGate = new Promise((resolve) => {
releaseInterceptor = resolve;
});
terminalBridge.init({
sessions: new Map([["s", session]]),
electronModule: {},
terminalDataPipeline: {
has: () => true,
interceptInput: async (_sessionId, data) => {
assert.equal(data, "\r");
await interceptorGate;
return "\r\n";
},
},
});
try {
terminalBridge.writeToSession({}, { sessionId: "s", data: "\r" });
await Promise.resolve();
session.autoLogin.handleText("login: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r"]);
releaseInterceptor();
const deadline = Date.now() + 500;
while (writes.length < 2 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r\n"]);
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, []);
assert.deepEqual(writes, ["admin\r", "\r\n", "secret\r"]);
} finally {
releaseInterceptor();
terminalBridge.cleanupAllSessions();
}
});
test("serial line-mode notification still cancels auto-login without keystroke data", () => {
const { session, writes, events } = createSerialAutoLoginSession();
terminalBridge.init({ sessions: new Map([["s", session]]), electronModule: {} });
try {
terminalBridge.notifySessionUserInput({}, { sessionId: "s" });
session.autoLogin.handleText("login: ");
session.autoLogin.handleText("\r\nPassword: ");
assert.deepEqual(events, ["cancelled"]);
assert.deepEqual(writes, []);
} finally {
terminalBridge.cleanupAllSessions();
}
});
test("serial sessions without auto-login credentials accept input normally", () => {
const writes = [];
const session = {
+84 -308
View File
@@ -1,8 +1,7 @@
diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
index bb495d1..32c48a0 100644
--- a/node_modules/ssh2/lib/agent.js
+++ b/node_modules/ssh2/lib/agent.js
@@ -6,7 +6,7 @@ const { resolve } = require('path');
@@ -6,7 +6,7 @@
const { readFile } = require('fs');
const { execFile, spawn } = require('child_process');
@@ -11,7 +10,7 @@ index bb495d1..32c48a0 100644
const {
makeBufferParser,
@@ -655,8 +655,12 @@ const AgentProtocol = (() => {
@@ -655,8 +655,12 @@
// TODO: verify signature type based on key and options used
// during initial sign request
binaryParser.init(signature, 0);
@@ -26,7 +25,7 @@ index bb495d1..32c48a0 100644
binaryParser.clear();
if (signature === undefined)
@@ -711,7 +715,10 @@ const AgentProtocol = (() => {
@@ -711,7 +715,10 @@
hash: undefined,
};
let ctx;
@@ -38,7 +37,7 @@ index bb495d1..32c48a0 100644
if (flagsVal & SSH_AGENT_RSA_SHA2_256) {
ctx = 'rsa-sha2-256';
flags.hash = 'sha256';
@@ -721,7 +728,7 @@ const AgentProtocol = (() => {
@@ -721,7 +728,7 @@
}
}
if (ctx === undefined)
@@ -47,7 +46,7 @@ index bb495d1..32c48a0 100644
const req = new AgentInboundRequest(msgType, ctx);
this[SYM_REQS].push(req);
@@ -732,6 +739,9 @@ const AgentProtocol = (() => {
@@ -732,6 +739,9 @@
default: {
const req = new AgentInboundRequest(msgType);
this[SYM_REQS].push(req);
@@ -57,7 +56,7 @@ index bb495d1..32c48a0 100644
this.failureReply(req);
}
}
@@ -781,7 +791,11 @@ const AgentProtocol = (() => {
@@ -781,7 +791,11 @@
if (pubKey instanceof Error)
throw new Error('Invalid public key argument');
@@ -70,7 +69,7 @@ index bb495d1..32c48a0 100644
switch (options.hash) {
case 'sha256':
flags = SSH_AGENT_RSA_SHA2_256;
@@ -992,19 +1006,24 @@ const AgentProtocol = (() => {
@@ -992,19 +1006,24 @@
let p = 0;
const sigFormat = req.getContext();
const sigFormatLen = Buffer.byteLength(sigFormat);
@@ -100,10 +99,9 @@ index bb495d1..32c48a0 100644
return respond(this, req, buf);
}
diff --git a/node_modules/ssh2/lib/client.js b/node_modules/ssh2/lib/client.js
index 7291c2c..8943c9a 100644
--- a/node_modules/ssh2/lib/client.js
+++ b/node_modules/ssh2/lib/client.js
@@ -2170,6 +2170,29 @@ function getKeyAlgos(client, key, serverSigAlgs) {
@@ -2170,6 +2170,29 @@
return [];
}
return [['ssh-rsa', 'sha1']];
@@ -134,10 +132,9 @@ index 7291c2c..8943c9a 100644
}
diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/protocol/Protocol.js
index 7302488..df0c9b3 100644
--- a/node_modules/ssh2/lib/protocol/Protocol.js
+++ b/node_modules/ssh2/lib/protocol/Protocol.js
@@ -58,6 +58,7 @@ const {
@@ -58,6 +58,7 @@
} = require('./kex.js');
const {
parseKey,
@@ -145,7 +142,7 @@ index 7302488..df0c9b3 100644
} = require('./keyParser.js');
const MESSAGE_HANDLERS = require('./handlers.js');
const {
@@ -701,11 +702,19 @@ class Protocol {
@@ -701,11 +702,19 @@
if (signature === false)
throw new Error('Error while converting handshake signature');
@@ -166,7 +163,7 @@ index 7302488..df0c9b3 100644
);
// TODO: simply copy from original "packet" to new `packet` to avoid
@@ -729,13 +738,17 @@ class Protocol {
@@ -729,13 +738,17 @@
writeUInt32BE(packet, pubKeyLen, p += algoLen);
packet.set(pubKey, p += 4);
@@ -189,7 +186,7 @@ index 7302488..df0c9b3 100644
// Servers shouldn't send packet type 60 in response to signed publickey
// attempts, but if they do, interpret as type 60.
@@ -810,20 +823,32 @@ class Protocol {
@@ -810,20 +823,32 @@
if (!signature)
throw new Error('Error while converting handshake signature');
@@ -228,7 +225,7 @@ index 7302488..df0c9b3 100644
this._authsQueue.push('hostbased');
@@ -1916,7 +1941,10 @@ class Protocol {
@@ -1916,7 +1941,10 @@
}
// SSH-protoversion-softwareversion (SP comments) CR LF
@@ -241,10 +238,9 @@ index 7302488..df0c9b3 100644
// TODO: optimize this by starting n bytes from the end of this._buffer instead
// of the beginning
diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/protocol/SFTP.js
index 9f33c02..9751164 100644
--- a/node_modules/ssh2/lib/protocol/SFTP.js
+++ b/node_modules/ssh2/lib/protocol/SFTP.js
@@ -117,6 +117,20 @@ const OPENSSH_MAX_PKT_LEN = 256 * 1024;
@@ -117,6 +117,20 @@
const bufferParser = makeBufferParser();
@@ -265,7 +261,7 @@ index 9f33c02..9751164 100644
const fakeStderr = {
readable: false,
writable: false,
@@ -155,6 +169,8 @@ class SFTP extends EventEmitter {
@@ -155,6 +169,8 @@
this._writeReqid = -1;
this._requests = {};
this._maxInPktLen = OPENSSH_MAX_PKT_LEN;
@@ -274,7 +270,7 @@ index 9f33c02..9751164 100644
this._maxOutPktLen = 34000;
this._maxReadLen =
(this._isOpenSSH ? OPENSSH_MAX_PKT_LEN : 34000) - PKT_RW_OVERHEAD;
@@ -196,6 +212,53 @@ class SFTP extends EventEmitter {
@@ -196,6 +212,53 @@
this.emit('end');
return;
}
@@ -328,7 +324,7 @@ index 9f33c02..9751164 100644
/*
uint32 length
byte type
@@ -339,7 +402,7 @@ class SFTP extends EventEmitter {
@@ -339,7 +402,7 @@
uint32 pflags
ATTRS attrs
*/
@@ -337,7 +333,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + 4 + attrsLen);
@@ -349,7 +412,7 @@ class SFTP extends EventEmitter {
@@ -349,7 +412,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -346,7 +342,7 @@ index 9f33c02..9751164 100644
writeUInt32BE(buf, flags, p += pathLen);
writeUInt32BE(buf, attrsFlags, p += 4);
if (attrsLen) {
@@ -734,7 +797,7 @@ class SFTP extends EventEmitter {
@@ -734,7 +797,7 @@
uint32 id
string filename
*/
@@ -355,7 +351,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + fnameLen);
@@ -744,7 +807,7 @@ class SFTP extends EventEmitter {
@@ -744,7 +807,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, fnameLen, p);
@@ -364,7 +360,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -762,8 +825,8 @@ class SFTP extends EventEmitter {
@@ -762,8 +825,8 @@
string oldpath
string newpath
*/
@@ -375,7 +371,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + oldLen + 4 + newLen);
@@ -773,9 +836,9 @@ class SFTP extends EventEmitter {
@@ -773,9 +836,9 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, oldLen, p);
@@ -387,7 +383,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -806,7 +869,7 @@ class SFTP extends EventEmitter {
@@ -806,7 +869,7 @@
string path
ATTRS attrs
*/
@@ -396,7 +392,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
@@ -816,7 +879,7 @@ class SFTP extends EventEmitter {
@@ -816,7 +879,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -405,7 +401,7 @@ index 9f33c02..9751164 100644
writeUInt32BE(buf, flags, p += pathLen);
if (attrsLen) {
p += 4;
@@ -844,7 +907,7 @@ class SFTP extends EventEmitter {
@@ -844,7 +907,7 @@
uint32 id
string path
*/
@@ -414,7 +410,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -854,7 +917,7 @@ class SFTP extends EventEmitter {
@@ -854,7 +917,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -423,7 +419,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -987,7 +1050,7 @@ class SFTP extends EventEmitter {
@@ -987,7 +1050,7 @@
uint32 id
string path
*/
@@ -432,7 +428,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -997,7 +1060,7 @@ class SFTP extends EventEmitter {
@@ -997,7 +1060,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -441,7 +437,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -1014,7 +1077,7 @@ class SFTP extends EventEmitter {
@@ -1014,7 +1077,7 @@
uint32 id
string path
*/
@@ -450,7 +446,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1024,7 +1087,7 @@ class SFTP extends EventEmitter {
@@ -1024,7 +1087,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -459,7 +455,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -1041,7 +1104,7 @@ class SFTP extends EventEmitter {
@@ -1041,7 +1104,7 @@
uint32 id
string path
*/
@@ -468,7 +464,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1051,7 +1114,7 @@ class SFTP extends EventEmitter {
@@ -1051,7 +1114,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -477,7 +473,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -1080,7 +1143,7 @@ class SFTP extends EventEmitter {
@@ -1080,7 +1143,7 @@
string path
ATTRS attrs
*/
@@ -486,7 +482,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
@@ -1090,7 +1153,7 @@ class SFTP extends EventEmitter {
@@ -1090,7 +1153,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -495,7 +491,7 @@ index 9f33c02..9751164 100644
writeUInt32BE(buf, flags, p += pathLen);
if (attrsLen) {
p += 4;
@@ -1205,7 +1268,7 @@ class SFTP extends EventEmitter {
@@ -1205,7 +1268,7 @@
uint32 id
string path
*/
@@ -504,7 +500,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1215,7 +1278,7 @@ class SFTP extends EventEmitter {
@@ -1215,7 +1278,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -513,7 +509,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = {
cb: (err, names) => {
@@ -1243,8 +1306,8 @@ class SFTP extends EventEmitter {
@@ -1243,8 +1306,8 @@
string linkpath
string targetpath
*/
@@ -524,7 +520,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + linkLen + 4 + targetLen);
@@ -1256,14 +1319,14 @@ class SFTP extends EventEmitter {
@@ -1256,14 +1319,14 @@
if (this._isOpenSSH) {
// OpenSSH has linkpath and targetpath positions switched
writeUInt32BE(buf, targetLen, p);
@@ -543,7 +539,7 @@ index 9f33c02..9751164 100644
}
this._requests[reqid] = { cb };
@@ -1281,7 +1344,7 @@ class SFTP extends EventEmitter {
@@ -1281,7 +1344,7 @@
uint32 id
string path
*/
@@ -552,7 +548,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
@@ -1291,7 +1354,7 @@ class SFTP extends EventEmitter {
@@ -1291,7 +1354,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, pathLen, p);
@@ -561,7 +557,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = {
cb: (err, names) => {
@@ -1325,8 +1388,8 @@ class SFTP extends EventEmitter {
@@ -1325,8 +1388,8 @@
string oldpath
string newpath
*/
@@ -572,7 +568,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf =
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 24 + 4 + oldLen + 4 + newLen);
@@ -1337,11 +1400,11 @@ class SFTP extends EventEmitter {
@@ -1337,11 +1400,11 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 24, p);
@@ -587,7 +583,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -1364,7 +1427,7 @@ class SFTP extends EventEmitter {
@@ -1364,7 +1427,7 @@
string "statvfs@openssh.com"
string path
*/
@@ -596,7 +592,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 19 + 4 + pathLen);
@@ -1374,9 +1437,9 @@ class SFTP extends EventEmitter {
@@ -1374,9 +1437,9 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 19, p);
@@ -608,7 +604,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { extended: 'statvfs@openssh.com', cb };
@@ -1411,7 +1474,7 @@ class SFTP extends EventEmitter {
@@ -1411,7 +1474,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 20, p);
@@ -617,7 +613,7 @@ index 9f33c02..9751164 100644
writeUInt32BE(buf, handleLen, p += 20);
buf.set(handle, p += 4);
@@ -1437,8 +1500,8 @@ class SFTP extends EventEmitter {
@@ -1437,8 +1500,8 @@
string oldpath
string newpath
*/
@@ -628,7 +624,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf =
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + oldLen + 4 + newLen);
@@ -1449,11 +1512,11 @@ class SFTP extends EventEmitter {
@@ -1449,11 +1512,11 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 20, p);
@@ -643,7 +639,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = { cb };
@@ -1488,7 +1551,7 @@ class SFTP extends EventEmitter {
@@ -1488,7 +1551,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 17, p);
@@ -652,7 +648,7 @@ index 9f33c02..9751164 100644
writeUInt32BE(buf, handleLen, p += 17);
buf.set(handle, p += 4);
@@ -1524,7 +1587,7 @@ class SFTP extends EventEmitter {
@@ -1524,7 +1587,7 @@
string path
ATTRS attrs
*/
@@ -661,7 +657,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf =
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + pathLen + 4 + attrsLen);
@@ -1535,10 +1598,10 @@ class SFTP extends EventEmitter {
@@ -1535,10 +1598,10 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 20, p);
@@ -674,7 +670,7 @@ index 9f33c02..9751164 100644
writeUInt32BE(buf, flags, p += pathLen);
if (attrsLen) {
@@ -1573,7 +1636,7 @@ class SFTP extends EventEmitter {
@@ -1573,7 +1636,7 @@
string "expand-path@openssh.com"
string path
*/
@@ -683,7 +679,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 23 + 4 + pathLen);
@@ -1583,10 +1646,10 @@ class SFTP extends EventEmitter {
@@ -1583,10 +1646,10 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 23, p);
@@ -696,7 +692,7 @@ index 9f33c02..9751164 100644
this._requests[reqid] = {
cb: (err, names) => {
@@ -1653,7 +1716,7 @@ class SFTP extends EventEmitter {
@@ -1653,7 +1716,7 @@
writeUInt32BE(buf, 9, p);
p += 4;
@@ -705,7 +701,7 @@ index 9f33c02..9751164 100644
p += 9;
writeUInt32BE(buf, srcHandle.length, p);
@@ -1708,7 +1771,7 @@ class SFTP extends EventEmitter {
@@ -1708,7 +1771,7 @@
string username
*/
let p = 0;
@@ -714,7 +710,7 @@ index 9f33c02..9751164 100644
const buf = Buffer.allocUnsafe(
4 + 1
+ 4
@@ -1728,12 +1791,12 @@ class SFTP extends EventEmitter {
@@ -1728,12 +1791,12 @@
writeUInt32BE(buf, 14, p);
p += 4;
@@ -729,7 +725,7 @@ index 9f33c02..9751164 100644
p += usernameLen;
this._requests[reqid] = {
@@ -1806,7 +1869,7 @@ class SFTP extends EventEmitter {
@@ -1806,7 +1869,7 @@
writeUInt32BE(buf, 30, p);
p += 4;
@@ -738,7 +734,7 @@ index 9f33c02..9751164 100644
p += 30;
writeUInt32BE(buf, 4 * uids.length, p);
@@ -1871,7 +1934,7 @@ class SFTP extends EventEmitter {
@@ -1871,7 +1934,7 @@
message || (message = '');
@@ -747,7 +743,7 @@ index 9f33c02..9751164 100644
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 4 + msgLen + 4);
@@ -1884,7 +1947,7 @@ class SFTP extends EventEmitter {
@@ -1884,7 +1947,7 @@
writeUInt32BE(buf, msgLen, p += 4);
p += 4;
if (msgLen) {
@@ -756,7 +752,7 @@ index 9f33c02..9751164 100644
p += msgLen;
}
@@ -1913,7 +1976,7 @@ class SFTP extends EventEmitter {
@@ -1913,7 +1976,7 @@
const dataLen = (
isBuffer
? data.length
@@ -765,7 +761,7 @@ index 9f33c02..9751164 100644
);
let p = 9;
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + dataLen);
@@ -1927,7 +1990,7 @@ class SFTP extends EventEmitter {
@@ -1927,7 +1990,7 @@
if (isBuffer)
buf.set(data, p += 4);
else if (isUTF8)
@@ -774,7 +770,7 @@ index 9f33c02..9751164 100644
else
buf.write(data, p += 4, dataLen, encoding);
}
@@ -1959,13 +2022,13 @@ class SFTP extends EventEmitter {
@@ -1959,13 +2022,13 @@
? ''
: name.filename
);
@@ -790,7 +786,7 @@ index 9f33c02..9751164 100644
if (typeof name.attrs === 'object' && name.attrs !== null) {
nameAttrs = attrsToBytes(name.attrs);
@@ -2011,11 +2074,11 @@ class SFTP extends EventEmitter {
@@ -2011,11 +2074,11 @@
? ''
: name.filename
);
@@ -804,7 +800,7 @@ index 9f33c02..9751164 100644
p += len;
}
}
@@ -2026,11 +2089,11 @@ class SFTP extends EventEmitter {
@@ -2026,11 +2089,11 @@
? ''
: name.longname
);
@@ -818,7 +814,7 @@ index 9f33c02..9751164 100644
p += len;
}
}
@@ -2749,7 +2812,7 @@ function requestLimits(sftp, cb) {
@@ -2749,7 +2812,7 @@
writeUInt32BE(buf, reqid, 5);
writeUInt32BE(buf, 18, p);
@@ -827,7 +823,7 @@ index 9f33c02..9751164 100644
sftp._requests[reqid] = { extended: 'limits@openssh.com', cb };
@@ -2953,18 +3016,28 @@ const CLIENT_HANDLERS = {
@@ -2953,18 +3016,28 @@
// spec not specifying an encoding because the specs for newer
// versions of the protocol all explicitly specify UTF-8 for
// filenames
@@ -860,41 +856,21 @@ index 9f33c02..9751164 100644
if (names !== undefined) {
sftp._debug && sftp._debug(
diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib/protocol/constants.js
index ad77592..a249e00 100644
--- a/node_modules/ssh2/lib/protocol/constants.js
+++ b/node_modules/ssh2/lib/protocol/constants.js
@@ -71,9 +71,30 @@ const DEFAULT_SERVER_HOST_KEY = [
];
if (eddsaSupported)
@@ -73,7 +73,11 @@
DEFAULT_SERVER_HOST_KEY.unshift('ssh-ed25519');
+// OpenSSH host certificates, offered before the matching plain key so a
+// cert-only server (for example ssh-ed25519-cert-v01@openssh.com) can
+// negotiate. Plain keys stay as fallbacks.
+const OPENSSH_HOST_CERT_ALGS = [
+ 'ecdsa-sha2-nistp256-cert-v01@openssh.com',
+ 'ecdsa-sha2-nistp384-cert-v01@openssh.com',
+ 'ecdsa-sha2-nistp521-cert-v01@openssh.com',
+ 'rsa-sha2-512-cert-v01@openssh.com',
+ 'rsa-sha2-256-cert-v01@openssh.com',
+ 'ssh-rsa-cert-v01@openssh.com',
+];
+if (eddsaSupported)
+ OPENSSH_HOST_CERT_ALGS.unshift('ssh-ed25519-cert-v01@openssh.com');
+DEFAULT_SERVER_HOST_KEY.unshift(...OPENSSH_HOST_CERT_ALGS);
const SUPPORTED_SERVER_HOST_KEY = DEFAULT_SERVER_HOST_KEY.concat([
'ssh-dss',
+ // FIDO/U2F security keys (client publickey auth / rare host keys).
+ 'sk-ecdsa-sha2-nistp256@openssh.com',
+ 'sk-ecdsa-sha2-nistp256-cert-v01@openssh.com',
]);
+if (eddsaSupported) {
+if (eddsaSupported)
+ SUPPORTED_SERVER_HOST_KEY.push('sk-ssh-ed25519@openssh.com');
+ SUPPORTED_SERVER_HOST_KEY.push('sk-ssh-ed25519-cert-v01@openssh.com');
+}
const canUseCipher = (() => {
@@ -160,6 +181,7 @@ const COMPAT = {
@@ -160,6 +164,7 @@
DYN_RPORT_BUG: 1 << 2,
BUG_DHGEX_LARGE: 1 << 3,
IMPLY_RSA_SHA2_SIGALGS: 1 << 4,
@@ -902,7 +878,7 @@ index ad77592..a249e00 100644
};
module.exports = {
@@ -330,6 +352,7 @@ module.exports = {
@@ -330,6 +335,7 @@
COMPAT_CHECKS: [
[ 'Cisco-1.25', COMPAT.BAD_DHGEX ],
[ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ],
@@ -911,198 +887,9 @@ index ad77592..a249e00 100644
[ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ],
[ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ],
diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/protocol/kex.js
index 811e631..422a198 100644
--- a/node_modules/ssh2/lib/protocol/kex.js
+++ b/node_modules/ssh2/lib/protocol/kex.js
@@ -30,7 +30,10 @@ const {
createDecipher,
MAC_INFO,
} = require('./crypto.js');
-const { parseDERKey } = require('./keyParser.js');
+const {
+ parseDERKey,
+ getOpenSSHCertSignatureAlgorithm,
+} = require('./keyParser.js');
const {
bufferFill,
bufferParser,
@@ -49,6 +52,68 @@ const {
let MESSAGE_HANDLERS;
+const LEGACY_RSA_HOST_CERT = /^ssh-rsa-cert-v0[01]@openssh\.com$/;
+const RSA_SHA2_256_HOST_CERT = /^rsa-sha2-256-cert-v0[01]@openssh\.com$/;
+const RSA_SHA2_512_HOST_CERT = /^rsa-sha2-512-cert-v0[01]@openssh\.com$/;
+const LEGACY_RSA_HOST_SIGNATURES = new Set([
+ 'rsa-sha2-256',
+ 'rsa-sha2-512',
+ 'ssh-rsa',
+]);
+
+// Client KEX accepts the negotiated host-key name or, for OpenSSH host
+// certificates, the underlying key algorithm. Real servers sign with the
+// embedded key (ssh-ed25519, ecdsa-sha2-*, rsa-sha2-*) rather than the
+// certificate algorithm name. OpenSSH allows any RSA signature only for the
+// legacy ssh-rsa certificate. rsa-sha2-256/512 certificates must use the
+// hash that was negotiated.
+function hostKeySignatureTypeAccepted(negotiatedServerHostKey, sigType) {
+ if (RSA_SHA2_256_HOST_CERT.test(negotiatedServerHostKey))
+ return sigType === 'rsa-sha2-256';
+ if (RSA_SHA2_512_HOST_CERT.test(negotiatedServerHostKey))
+ return sigType === 'rsa-sha2-512';
+ if (sigType === negotiatedServerHostKey)
+ return true;
+ if (sigType === getOpenSSHCertSignatureAlgorithm(negotiatedServerHostKey))
+ return true;
+ return LEGACY_RSA_HOST_CERT.test(negotiatedServerHostKey)
+ && LEGACY_RSA_HOST_SIGNATURES.has(sigType);
+}
+
+// Plain rsa-sha2-* keeps an ssh-rsa key blob. An RSA certificate negotiation
+// is KEY_RSA_CERT: the blob must be ssh-rsa-cert-v01@openssh.com, never a
+// plain ssh-rsa key.
+function hostKeyBlobTypeAccepted(negotiatedServerHostKey, hostPubKeyType) {
+ if (hostPubKeyType === negotiatedServerHostKey)
+ return true;
+ switch (negotiatedServerHostKey) {
+ case 'rsa-sha2-256':
+ case 'rsa-sha2-512':
+ return hostPubKeyType === 'ssh-rsa';
+ case 'rsa-sha2-256-cert-v01@openssh.com':
+ case 'rsa-sha2-512-cert-v01@openssh.com':
+ return hostPubKeyType === 'ssh-rsa-cert-v01@openssh.com';
+ default:
+ return false;
+ }
+}
+
+function hashAlgoForHostKey(negotiatedServerHostKey, sigType) {
+ // Negotiated RSA-SHA2 wins over the signature label so a mismatched
+ // signature cannot select a weaker hash.
+ if (negotiatedServerHostKey === 'rsa-sha2-256'
+ || RSA_SHA2_256_HOST_CERT.test(negotiatedServerHostKey))
+ return 'sha256';
+ if (negotiatedServerHostKey === 'rsa-sha2-512'
+ || RSA_SHA2_512_HOST_CERT.test(negotiatedServerHostKey))
+ return 'sha512';
+ if (sigType === 'rsa-sha2-256')
+ return 'sha256';
+ if (sigType === 'rsa-sha2-512')
+ return 'sha512';
+ return undefined;
+}
+
const GEX_MIN_BITS = 2048; // RFC 8270
const GEX_MAX_BITS = 8192; // RFC 8270
@@ -635,7 +700,7 @@ const createKeyExchange = (() => {
);
}
- if (sigType !== negotiated.serverHostKey) {
+ if (!hostKeySignatureTypeAccepted(negotiated.serverHostKey, sigType)) {
return doFatalError(
this._protocol,
`Wrong signature type: ${sigType}, `
@@ -659,7 +724,10 @@ const createKeyExchange = (() => {
);
}
- if (!(sigValue = sigSSHToASN1(sigValue, sigType))) {
+ if (!(sigValue = sigSSHToASN1(
+ sigValue,
+ getOpenSSHCertSignatureAlgorithm(sigType)
+ ))) {
return doFatalError(
this._protocol,
'Malformed signature',
@@ -685,12 +753,10 @@ const createKeyExchange = (() => {
}
}
- let hashAlgo;
- // Check if we need to override the default hash algorithm
- switch (this.negotiated.serverHostKey) {
- case 'rsa-sha2-256': hashAlgo = 'sha256'; break;
- case 'rsa-sha2-512': hashAlgo = 'sha512'; break;
- }
+ const hashAlgo = hashAlgoForHostKey(
+ this.negotiated.serverHostKey,
+ sigType
+ );
this._protocol._debug
&& this._protocol._debug('Verifying signature ...');
@@ -717,12 +783,7 @@ const createKeyExchange = (() => {
} else {
// Server
- let hashAlgo;
- // Check if we need to override the default hash algorithm
- switch (this.negotiated.serverHostKey) {
- case 'rsa-sha2-256': hashAlgo = 'sha256'; break;
- case 'rsa-sha2-512': hashAlgo = 'sha512'; break;
- }
+ const hashAlgo = hashAlgoForHostKey(this.negotiated.serverHostKey);
this._protocol._debug && this._protocol._debug(
'Generating signature ...'
@@ -739,7 +800,12 @@ const createKeyExchange = (() => {
);
}
- signature = convertSignature(signature, this._hostKey.type);
+ // OpenSSH signs a host certificate with the embedded key algorithm,
+ // not the certificate algorithm name.
+ const sigType = getOpenSSHCertSignatureAlgorithm(
+ this.negotiated.serverHostKey
+ );
+ signature = convertSignature(signature, sigType);
if (signature === false) {
return doFatalError(
this._protocol,
@@ -759,7 +825,6 @@ const createKeyExchange = (() => {
string <method-specific data>
string signature of H
*/
- const sigType = this.negotiated.serverHostKey;
const sigTypeLen = Buffer.byteLength(sigType);
const sigLen = 4 + sigTypeLen + 4 + signature.length;
let p = this._protocol._packetRW.write.allocStartKEX;
@@ -1167,22 +1232,16 @@ const createKeyExchange = (() => {
DISCONNECT_REASON.KEY_EXCHANGE_FAILED
);
}
- if (hostPubKeyType !== this.negotiated.serverHostKey) {
- // Check if we need to make an exception
- switch (this.negotiated.serverHostKey) {
- case 'rsa-sha2-256':
- case 'rsa-sha2-512':
- if (hostPubKeyType === 'ssh-rsa')
- break;
- // FALLTHROUGH
- default:
- return doFatalError(
- this._protocol,
- 'Host key does not match negotiated type',
- 'handshake',
- DISCONNECT_REASON.KEY_EXCHANGE_FAILED
- );
- }
+ if (!hostKeyBlobTypeAccepted(
+ this.negotiated.serverHostKey,
+ hostPubKeyType
+ )) {
+ return doFatalError(
+ this._protocol,
+ 'Host key does not match negotiated type',
+ 'handshake',
+ DISCONNECT_REASON.KEY_EXCHANGE_FAILED
+ );
}
this._hostKey = hostPubKey;
@@ -1377,8 +1436,13 @@ const createKeyExchange = (() => {
@@ -1377,8 +1377,13 @@
this._generator = null;
this._minBits = GEX_MIN_BITS;
this._prefBits = dhEstimate(this.negotiated);
@@ -1117,20 +904,10 @@ index 811e631..422a198 100644
this._maxBits = GEX_MAX_BITS;
}
start() {
@@ -1868,6 +1932,8 @@ function trySendNEWKEYS(kex) {
module.exports = {
KexInit,
+ hostKeyBlobTypeAccepted,
+ hostKeySignatureTypeAccepted,
kexinit,
onKEXPayload,
DEFAULT_KEXINIT_CLIENT: new KexInit({
diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib/protocol/keyParser.js
index a276c1a..c8ff9d1 100644
--- a/node_modules/ssh2/lib/protocol/keyParser.js
+++ b/node_modules/ssh2/lib/protocol/keyParser.js
@@ -35,6 +35,9 @@ const SYM_PRIV_PEM = Symbol('Private key PEM');
@@ -35,6 +35,9 @@
const SYM_PUB_PEM = Symbol('Public key PEM');
const SYM_PUB_SSH = Symbol('Public key SSH');
const SYM_DECRYPTED = Symbol('Decrypted Key');
@@ -1140,7 +917,7 @@ index a276c1a..c8ff9d1 100644
// Create OpenSSL cipher name -> SSH cipher name conversion table
const CIPHER_INFO_OPENSSL = Object.create(null);
@@ -258,6 +261,55 @@ function genOpenSSHEdPub(pub) {
@@ -258,6 +261,55 @@
return publicKey;
}
@@ -1196,7 +973,7 @@ index a276c1a..c8ff9d1 100644
function genOpenSSLEdPriv(priv) {
const asnWriter = new Ber.Writer();
asnWriter.startSequence();
@@ -451,6 +503,82 @@ const BaseKey = {
@@ -451,6 +503,82 @@
},
};
@@ -1258,7 +1035,7 @@ index a276c1a..c8ff9d1 100644
+ signedData.set(dataHash, p);
+
+ let toVerify = rawSig;
+ if (getOpenSSHCertBaseType(this.type) === 'sk-ecdsa-sha2-nistp256@openssh.com') {
+ if (this.type === 'sk-ecdsa-sha2-nistp256@openssh.com') {
+ // Convert SSH ECDSA r,s blob to ASN.1 for OpenSSL
+ const { sigSSHToASN1 } = require('./utils.js');
+ toVerify = sigSSHToASN1(rawSig, 'ecdsa-sha2-nistp256');
@@ -1279,7 +1056,7 @@ index a276c1a..c8ff9d1 100644
function OpenSSH_Private(type, comment, privPEM, pubPEM, pubSSH, algo,
decrypted) {
@@ -463,6 +591,26 @@ function OpenSSH_Private(type, comment, privPEM, pubPEM, pubSSH, algo,
@@ -463,6 +591,26 @@
this[SYM_DECRYPTED] = decrypted;
}
OpenSSH_Private.prototype = BaseKey;
@@ -1306,7 +1083,7 @@ index a276c1a..c8ff9d1 100644
{
const regexp = /^-----BEGIN OPENSSH PRIVATE KEY-----(?:\r\n|\n)([\s\S]+)(?:\r\n|\n)-----END OPENSSH PRIVATE KEY-----$/;
OpenSSH_Private.parse = (str, passphrase) => {
@@ -771,6 +919,87 @@ OpenSSH_Private.prototype = BaseKey;
@@ -771,6 +919,87 @@
privPEM = genOpenSSLECDSAPriv(oid, ecpub, ecpriv);
break;
}
@@ -1394,7 +1171,7 @@ index a276c1a..c8ff9d1 100644
default:
return new Error(`Unsupported OpenSSH private key type: ${type}`);
}
@@ -1186,31 +1415,97 @@ function OpenSSH_Public(type, comment, pubPEM, pubSSH, algo) {
@@ -1186,31 +1415,97 @@
this[SYM_DECRYPTED] = false;
}
OpenSSH_Public.prototype = BaseKey;
@@ -1504,7 +1281,7 @@ index a276c1a..c8ff9d1 100644
};
}
@@ -1309,7 +1604,7 @@ RFC4716_Public.prototype = BaseKey;
@@ -1309,7 +1604,7 @@
}
@@ -1513,7 +1290,7 @@ index a276c1a..c8ff9d1 100644
if (!isSupportedKeyType(baseType))
return new Error(`Unsupported OpenSSH public key type: ${baseType}`);
@@ -1317,6 +1612,10 @@ function parseDER(data, baseType, comment, fullType) {
@@ -1317,6 +1612,10 @@
let oid;
let pubPEM = null;
let pubSSH = null;
@@ -1524,7 +1301,7 @@ index a276c1a..c8ff9d1 100644
switch (baseType) {
case 'ssh-rsa': {
@@ -1383,11 +1682,66 @@ function parseDER(data, baseType, comment, fullType) {
@@ -1383,11 +1682,66 @@
pubSSH = genOpenSSHECDSAPub(oid, ecpub);
break;
}
@@ -1592,7 +1369,7 @@ index a276c1a..c8ff9d1 100644
}
function isSupportedKeyType(type) {
@@ -1397,8 +1751,10 @@ function isSupportedKeyType(type) {
@@ -1397,8 +1751,10 @@
case 'ecdsa-sha2-nistp256':
case 'ecdsa-sha2-nistp384':
case 'ecdsa-sha2-nistp521':
@@ -1603,7 +1380,7 @@ index a276c1a..c8ff9d1 100644
if (eddsaSupported)
return true;
// FALLTHROUGH
@@ -1460,7 +1816,13 @@ function parseKey(data, passphrase) {
@@ -1460,7 +1816,13 @@
if (type !== undefined) {
data = binaryKeyParser.readRaw();
if (data !== undefined) {
@@ -1618,12 +1395,11 @@ index a276c1a..c8ff9d1 100644
// Ignore potentially useless errors in case the data was not actually
// in the binary format
if (ret instanceof Error)
@@ -1479,6 +1841,7 @@ function parseKey(data, passphrase) {
@@ -1479,6 +1841,7 @@
module.exports = {
isParsedKey,
isSupportedKeyType,
- parseDERKey: (data, type) => parseDER(data, type, '', type),
+ getOpenSSHCertSignatureAlgorithm,
+ parseDERKey: (data, type) => parseDER(data, getOpenSSHCertBaseType(type), '', type),
parseDERKey: (data, type) => parseDER(data, type, '', type),
parseKey,
};
-7
View File
@@ -128,13 +128,6 @@ test("asarUnpack no longer references removed legacy agent packages", () => {
test("asarUnpack keeps MCP server runtime deps unpacked", () => {
// @modelcontextprotocol/sdk is now a direct dep and the MCP server hard-requires it.
assert.ok(config.asarUnpack.includes("node_modules/@modelcontextprotocol/sdk/**/*"));
// externalMcpRedaction.cjs requires re2js. The launcher runs from
// app.asar.unpacked with ELECTRON_RUN_AS_NODE, which cannot see modules
// that exist only inside app.asar (issue #3910).
assert.ok(
config.asarUnpack.includes("node_modules/re2js/**/*"),
"external MCP bridge must unpack re2js so the stdio launcher can resolve it",
);
});
test("asarUnpack keeps Cursor SDK runtime deps unpacked", () => {