fix(ssh): normalize PKCS8 private keys before connecting (#1570)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-10-07 16:37:50 -05:00
1 parent f4164a74b2
commit 9c31dd41c7
4 files changed
+182 -1

No files matched your search

@@ -1,3 +1,6 @@
import { generateKeyPairSync } from "node:crypto";
import ssh2 from "ssh2";
import { parsePrivateKey } from "sshpk";
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import {
@@ -158,3 +161,64 @@ describe("validateKeyPair", () => {
expect(result.error).toMatch(/private key/i);
});
});
describe("PKCS#8 runtime key normalization", () => {
it.each(["ed25519", "rsa", "ec"] as const)(
"converts %s without changing its public key",
(type) => {
const { privateKey, publicKey } =
type === "rsa"
? generateKeyPairSync("rsa", { modulusLength: 2048 })
: type === "ec"
? generateKeyPairSync("ec", { namedCurve: "prime256v1" })
: generateKeyPairSync("ed25519");
const pem = privateKey
.export({ type: "pkcs8", format: "pem" })
.toString();
const prepared = preparePrivateKeyForSSH2(pem);
const parsed = ssh2.utils.parseKey(prepared);
expect(parsed).not.toBeInstanceOf(Error);
if (parsed instanceof Error) throw parsed;
expect(
parsePrivateKey(prepared).toPublic().toBuffer("pkcs8").toString(),
).toBe(publicKey.export({ type: "spki", format: "pem" }).toString());
},
);
it("decrypts encrypted PKCS#8 only with the correct passphrase", () => {
const { privateKey } = generateKeyPairSync("ed25519");
const pem = privateKey
.export({
type: "pkcs8",
format: "pem",
cipher: "aes-256-cbc",
passphrase: " key passphrase ",
})
.toString();
expect(
ssh2.utils.parseKey(preparePrivateKeyForSSH2(pem, " key passphrase ")),
).not.toBeInstanceOf(Error);
expect(() => preparePrivateKeyForSSH2(pem)).toThrow();
expect(() => preparePrivateKeyForSSH2(pem, "wrong")).toThrow();
});
it("keeps an already usable OpenSSH key unchanged", () => {
expect(preparePrivateKeyForSSH2(ED25519_PRIVATE).toString()).toBe(
ED25519_PRIVATE,
);
});
it("rejects unsupported X25519 keys", () => {
const { privateKey } = generateKeyPairSync("x25519");
const pem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
expect(() => preparePrivateKeyForSSH2(pem)).toThrow();
});
it("rejects malformed PKCS#8 instead of guessing from its header", () => {
expect(() =>
preparePrivateKeyForSSH2(
"-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----",
),
).toThrow();
});
});
+22 -1
View File
@@ -1,3 +1,5 @@
import { createPrivateKey } from "node:crypto";
import { parsePrivateKey } from "sshpk";
import { getErrorMessage } from "./error-message.js";
import ssh2Pkg from "ssh2";
const ssh2Utils = ssh2Pkg.utils;
@@ -246,7 +248,26 @@ export function preparePrivateKeyForSSH2(
privateKeyData: string,
passphrase?: string,
): Buffer {
const cleanKey = normalizePrivateKeyText(privateKeyData);
let cleanKey = normalizePrivateKeyText(privateKeyData);
if (
/^-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/.test(cleanKey) &&
ssh2Utils.parseKey(cleanKey, passphrase) instanceof Error
) {
try {
const pem = createPrivateKey({ key: cleanKey, format: "pem", passphrase })
.export({ type: "pkcs8", format: "pem" })
.toString();
cleanKey = parsePrivateKey(pem, "pkcs8").toString("openssh");
} catch {
throw new Error(
cleanKey.startsWith("-----BEGIN ENCRYPTED PRIVATE KEY-----")
? "Could not decrypt PKCS#8 private key; check the passphrase"
: "Invalid or unsupported PKCS#8 private key",
);
}
const parsed = ssh2Utils.parseKey(cleanKey);
if (parsed instanceof Error) throw parsed;
}
const keyInfo = parseSSHKey(cleanKey, passphrase);
if (!keyInfo.success) {