fix: unlock passphrase keys once per connect burst (#1737)

This commit is contained in:
LukeGus committed 2026-10-29 12:40:38 -05:00
1 parent 27525d8f72
commit d715d097c5
3 files changed
+77 -2

No files matched your search

+1
View File
@@ -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
@@ -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);
+49 -2
View File
@@ -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<string, { text: string; expires: number }>();
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