Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 558525b59f fix(ssh): don't re-prompt host key on rekey; answer it synchronously
ssh2 re-runs hostVerifier on every key re-exchange. When the knownHosts
snapshot for the connection doesn't contain the key (first connect with
"Add and continue", "Continue" without saving), the verifier showed the
host-key dialog again and answered asynchronously. While that answer is
pending ssh2 keeps sending channel data ahead of its NEWKEYS, and OpenSSH
drops the connection ("Bad packet length"), killing the shell, rz/sz and
SFTP on that transport. Servers or gateways with a RekeyLimit hit this
after a few MB of transfer.

Like OpenSSH, accept a rekey only if it presents the key already accepted
for this connection, and answer synchronously without prompting.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-22 03:59:15 +00:00
2 changed files with 167 additions and 56 deletions

No files matched your search

+73 -56
View File
@@ -201,6 +201,8 @@ const requestHostKeyVerification = (sender, info) => new Promise((resolve) => {
}
});
const toKeyBuffer = (rawKey) => (Buffer.isBuffer(rawKey) ? rawKey : Buffer.from(rawKey || ""));
const createHostVerifier = ({
sender,
sessionId,
@@ -213,64 +215,79 @@ const createHostVerifier = ({
// TTL leaves the upload on "Waiting..." with no dialog and no error.
unattended = false,
onAccepted,
}) => (rawKey, callback) => {
const keyInfo = describeHostKey(rawKey);
const noteAccepted = () => {
try {
onAccepted?.({
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
publicKey: keyInfo.publicKey,
});
} catch {
// Recording the accepted key must not change the verifier result.
}) => {
// ssh2 calls the verifier again on every rekey. A prompt there is async,
// and while it waits ssh2 keeps sending channel data ahead of its NEWKEYS,
// so the server drops the connection mid-transfer ("Bad packet length").
// Like OpenSSH, a rekey only has to present the key already accepted for
// this connection: answer it synchronously and never prompt again.
let acceptedKey = null;
return (rawKey, callback) => {
if (verifyHostKeys !== false && acceptedKey) {
callback(acceptedKey.equals(toKeyBuffer(rawKey)));
return;
}
const keyInfo = describeHostKey(rawKey);
const noteAccepted = () => {
acceptedKey = Buffer.from(toKeyBuffer(rawKey));
try {
onAccepted?.({
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
publicKey: keyInfo.publicKey,
});
} catch {
// Recording the accepted key must not change the verifier result.
}
};
if (verifyHostKeys === false) {
noteAccepted();
callback(true);
return;
}
const decision = classifyHostKey({
knownHosts,
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
});
if (decision.status === "trusted") {
noteAccepted();
callback(true);
return;
}
if (unattended) {
callback(false);
return;
}
void requestHostKeyVerification(sender, {
sessionId,
hostname,
port,
status: decision.status,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
publicKey: keyInfo.publicKey,
knownHostId: decision.knownHost?.id,
knownFingerprint: decision.expectedFingerprint,
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
}).then((response) => {
if (response?.accept) noteAccepted();
callback(Boolean(response?.accept));
}).catch(() => {
callback(false);
});
};
if (verifyHostKeys === false) {
noteAccepted();
callback(true);
return;
}
const decision = classifyHostKey({
knownHosts,
hostname,
port,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
});
if (decision.status === "trusted") {
noteAccepted();
callback(true);
return;
}
if (unattended) {
callback(false);
return;
}
void requestHostKeyVerification(sender, {
sessionId,
hostname,
port,
status: decision.status,
keyType: keyInfo.keyType,
fingerprint: keyInfo.fingerprint,
publicKey: keyInfo.publicKey,
knownHostId: decision.knownHost?.id,
knownFingerprint: decision.expectedFingerprint,
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
}).then((response) => {
if (response?.accept) noteAccepted();
callback(Boolean(response?.accept));
}).catch(() => {
callback(false);
});
};
const handleResponse = (_event, payload) => {
+94
View File
@@ -356,6 +356,100 @@ test("createHostVerifier prompts for unknown host keys and waits for user respon
assert.equal(await acceptedPromise, true);
});
test("createHostVerifier answers rekeys synchronously with the key accepted at connect", async () => {
const rawKey = Buffer.from("new server key");
const sent = [];
const sender = {
id: 1,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
// The knownHosts snapshot stays empty for the life of the connection, as it
// does when the user picks "Continue" or "Add and continue" on first connect.
const verifier = createHostVerifier({
sender,
sessionId: "session-1",
hostname: "switch.local",
port: 22,
knownHosts: [],
});
const acceptedPromise = new Promise((resolve) => verifier(rawKey, resolve));
assert.equal(sent.length, 1);
handleResponse({ sender: { id: sender.id } }, {
requestId: sent[0].payload.requestId,
accept: true,
addToKnownHosts: false,
});
assert.equal(await acceptedPromise, true);
// ssh2 re-runs the verifier on every rekey. It must answer before returning
// (ssh2 keeps sending channel data while an async answer is pending) and
// must not show the host-key dialog again.
const rekeyResults = [];
verifier(Buffer.from(rawKey), (ok) => rekeyResults.push(ok));
assert.deepEqual(rekeyResults, [true]);
assert.equal(sent.length, 1);
});
test("createHostVerifier rejects a different host key on rekey without prompting", async () => {
const rawKey = Buffer.from("trusted server key");
const fingerprint = crypto.createHash("sha256").update(rawKey).digest("base64").replace(/=+$/g, "");
const sent = [];
const sender = {
id: 1,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
const verifier = createHostVerifier({
sender,
sessionId: "session-1",
hostname: "switch.local",
port: 22,
knownHosts: [{
id: "kh-1",
hostname: "switch.local",
port: 22,
keyType: "unknown",
publicKey: `SHA256:${fingerprint}`,
discoveredAt: 1,
}],
});
assert.equal(await new Promise((resolve) => verifier(rawKey, resolve)), true);
const rekeyResults = [];
verifier(Buffer.from("other server key"), (ok) => rekeyResults.push(ok));
assert.deepEqual(rekeyResults, [false]);
assert.deepEqual(sent, []);
});
test("createHostVerifier still prompts on the next connect after a rejected key", async () => {
const rawKey = Buffer.from("new server key");
const sent = [];
const sender = {
id: 1,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
const verifier = createHostVerifier({
sender,
sessionId: "session-1",
hostname: "switch.local",
port: 22,
knownHosts: [],
});
const first = new Promise((resolve) => verifier(rawKey, resolve));
handleResponse({ sender: { id: sender.id } }, { requestId: sent[0].payload.requestId, accept: false });
assert.equal(await first, false);
const second = new Promise((resolve) => verifier(rawKey, resolve));
assert.equal(sent.length, 2);
handleResponse({ sender: { id: sender.id } }, { requestId: sent[1].payload.requestId, accept: true });
assert.equal(await second, true);
});
test("createHostVerifier forwards bootEpoch on host-key prompts", async () => {
const rawKey = Buffer.from("new server key");
const sent = [];