mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 15:45:23 +00:00
fix: unlock passphrase keys once per connect burst (#1737)
This commit is contained in:
1 parent
27525d8f72
commit
d715d097c5
3 files changed
+77
-2
No files matched your search
@@ -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
|
- 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
|
- 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
|
## 26.10.5
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getFriendlyKeyTypeName,
|
getFriendlyKeyTypeName,
|
||||||
validateKeyPair,
|
validateKeyPair,
|
||||||
isSecurityKeyFile,
|
isSecurityKeyFile,
|
||||||
|
clearUnlockedKeyCache,
|
||||||
} from "../../utils/ssh-key-utils.js";
|
} from "../../utils/ssh-key-utils.js";
|
||||||
|
|
||||||
const { parsePrivateKey } = sshpk;
|
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", () => {
|
describe("security key files", () => {
|
||||||
const str = (v: string | Buffer) => {
|
const str = (v: string | Buffer) => {
|
||||||
const b = Buffer.from(v);
|
const b = Buffer.from(v);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createPrivateKey } from "node:crypto";
|
import { createHash, createPrivateKey } from "node:crypto";
|
||||||
import sshpk from "sshpk";
|
import sshpk from "sshpk";
|
||||||
import { getErrorMessage } from "./error-message.js";
|
import { getErrorMessage } from "./error-message.js";
|
||||||
import ssh2Pkg from "ssh2";
|
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(
|
export function preparePrivateKeyForSSH2(
|
||||||
privateKeyData: string,
|
privateKeyData: string,
|
||||||
passphrase?: string,
|
passphrase?: string,
|
||||||
): Buffer {
|
): Buffer {
|
||||||
if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR);
|
if (isSecurityKeyFile(privateKeyData)) throw new Error(SECURITY_KEY_ERROR);
|
||||||
let cleanKey = normalizePrivateKeyText(privateKeyData);
|
let cleanKey = unlockOpenSSHKey(
|
||||||
|
normalizePrivateKeyText(privateKeyData),
|
||||||
|
passphrase,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) &&
|
/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) &&
|
||||||
ssh2Utils.parseKey(cleanKey, passphrase) instanceof Error
|
ssh2Utils.parseKey(cleanKey, passphrase) instanceof Error
|
||||||
|
|||||||
Reference in new issue
Block a user