diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1e3de41..7bf637e2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - The log line for a rejected proxy login now shows the source address, so you can add it to TRUSTED_PROXY_AUTH_TRUSTED_PROXIES - Deleting your own account now removes all of its data, like an admin delete does, instead of leaving some behind +- An SSH key with a passphrase is unlocked once and kept unlocked in memory for a short time, so connecting to many hosts with it no longer freezes the server for minutes ## 26.10.5 diff --git a/src/backend/tests/utils/ssh-key-utils.test.ts b/src/backend/tests/utils/ssh-key-utils.test.ts index 5175e3993..3e15cc040 100644 --- a/src/backend/tests/utils/ssh-key-utils.test.ts +++ b/src/backend/tests/utils/ssh-key-utils.test.ts @@ -11,6 +11,7 @@ import { getFriendlyKeyTypeName, validateKeyPair, isSecurityKeyFile, + clearUnlockedKeyCache, } from "../../utils/ssh-key-utils.js"; const { parsePrivateKey } = sshpk; @@ -226,6 +227,32 @@ describe("PKCS#8 runtime key normalization", () => { }); }); +describe("encrypted OpenSSH keys", () => { + const key = sshpk.generatePrivateKey("ed25519"); + const encrypted = key.toString("openssh", { passphrase: "pw" }).trim(); + + it("unlocks once and hands ssh2 a key that needs no passphrase", () => { + clearUnlockedKeyCache(); + const first = preparePrivateKeyForSSH2(encrypted, "pw"); + const parsed = ssh2.utils.parseKey(first); + expect(parsed).not.toBeInstanceOf(Error); + expect(parsePrivateKey(first.toString()).fingerprint().toString()).toBe( + key.fingerprint().toString(), + ); + expect(preparePrivateKeyForSSH2(encrypted, "pw").toString()).toBe( + first.toString(), + ); + }); + + it("leaves the key locked for a wrong or missing passphrase", () => { + clearUnlockedKeyCache(); + expect(preparePrivateKeyForSSH2(encrypted, "wrong").toString()).toBe( + encrypted, + ); + expect(preparePrivateKeyForSSH2(encrypted).toString()).toBe(encrypted); + }); +}); + describe("security key files", () => { const str = (v: string | Buffer) => { const b = Buffer.from(v); diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts index 5bd699fd9..125d92031 100644 --- a/src/backend/utils/ssh-key-utils.ts +++ b/src/backend/utils/ssh-key-utils.ts @@ -1,4 +1,4 @@ -import { createPrivateKey } from "node:crypto"; +import { createHash, createPrivateKey } from "node:crypto"; import sshpk from "sshpk"; import { getErrorMessage } from "./error-message.js"; import ssh2Pkg from "ssh2"; @@ -264,12 +264,59 @@ export function isSecurityKeyFile(privateKeyData: string): boolean { } } +// Unlocking an OpenSSH key runs bcrypt on the main thread (about a second per +// try), and every connect used to do it 3 or more times. Keep the unlocked copy +// in memory for a short while so a burst of connects pays for it once. +const UNLOCKED_KEY_TTL_MS = 15 * 60 * 1000; +const UNLOCKED_KEY_MAX = 64; +const unlockedKeys = new Map(); + +function unlockOpenSSHKey(cleanKey: string, passphrase?: string): string { + if ( + !passphrase || + !cleanKey.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----") + ) + return cleanKey; + const id = createHash("sha256") + .update(cleanKey) + .update("\n") + .update(passphrase) + .digest("hex"); + const now = Date.now(); + const hit = unlockedKeys.get(id); + if (hit && hit.expires > now) return hit.text; + unlockedKeys.delete(id); + let text: string; + try { + text = parsePrivateKey(cleanKey, "openssh", { passphrase }).toString( + "openssh", + ); + } catch { + // Wrong passphrase or a cipher sshpk lacks; the normal path reports it. + return cleanKey; + } + if (ssh2Utils.parseKey(text) instanceof Error) return cleanKey; + if (unlockedKeys.size >= UNLOCKED_KEY_MAX) { + const oldest = unlockedKeys.keys().next().value; + if (oldest !== undefined) unlockedKeys.delete(oldest); + } + unlockedKeys.set(id, { text, expires: now + UNLOCKED_KEY_TTL_MS }); + return text; +} + +export function clearUnlockedKeyCache(): void { + unlockedKeys.clear(); +} + export function preparePrivateKeyForSSH2( privateKeyData: string, passphrase?: string, ): Buffer { if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR); - let cleanKey = normalizePrivateKeyText(privateKeyData); + let cleanKey = unlockOpenSSHKey( + normalizePrivateKeyText(privateKeyData), + passphrase, + ); if ( /^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) && ssh2Utils.parseKey(cleanKey, passphrase) instanceof Error