Files
Netcatty/electron/bridges/privateKeyNormalizer.test.cjs
陈大猫 8ca36a695b fix(ssh): repair mangled PEM private keys before parsing (#1147)
* fix(ssh): repair mangled PEM private keys before parsing

A valid PEM key whose framing was damaged in transit — newlines
collapsed to spaces, turned into literal "\n", or lines indented —
fails ssh2's parser with "Unsupported key format" even though the key
material is intact. This commonly happens when a key is copy/pasted
through a field or app that strips line breaks. (follow-up to #1139)

When parsing fails, rebuild clean PEM framing from the BEGIN/END markers
(which survive newline loss) and the base64 body, then retry through the
existing parse and PKCS#8 conversion paths. The body is preserved
byte-for-byte and a repaired key is only used if it re-validates, so
this can never produce a different or invalid key. Encrypted legacy PEM
(Proc-Type/DEK-Info) and truncated keys are left untouched.

Refs #1139

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ssh): detect encryption on mangled OpenSSH keys

A mangled encrypted OpenSSH key (line breaks flattened to literal "\n")
was not recognized as encrypted: the literal escapes corrupt the base64
decode used to read the cipher name, so isKeyEncrypted() returned false
and preparePrivateKeyForAuth routed the key to the unencrypted branch
with no passphrase prompt — and the repaired candidate was discarded
because it can't parse without one.

Repair the PEM framing before reading the OpenSSH cipher name, so such
keys are detected as encrypted and reach the passphrase prompt, where
normalizePrivateKeyForSsh2(key, passphrase) already repairs and
validates them. Addresses Codex review feedback on #1147.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-29 15:45:56 +08:00

142 lines
5.1 KiB
JavaScript

const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const { utils: sshUtils } = require("ssh2");
const {
normalizePrivateKeyForSsh2,
PrivateKeyPassphraseError,
UnsupportedPrivateKeyError,
} = require("./privateKeyNormalizer.cjs");
function genRsa(encoding) {
return crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: encoding,
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
}
const rsaPkcs8 = () => genRsa({ type: "pkcs8", format: "pem" });
const rsaPkcs1 = () => genRsa({ type: "pkcs1", format: "pem" });
const encryptedRsaPkcs8 = (passphrase) =>
genRsa({ type: "pkcs8", format: "pem", cipher: "aes-256-cbc", passphrase });
const ecPkcs8 = () =>
crypto.generateKeyPairSync("ec", {
namedCurve: "prime256v1",
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
const ed25519Pkcs8 = () =>
crypto.generateKeyPairSync("ed25519", {
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
function parseOk(key) {
const r = sshUtils.parseKey(key);
return r && !(r instanceof Error) ? r : null;
}
test("converts unencrypted RSA PKCS#8 into an ssh2-parseable key", () => {
const result = normalizePrivateKeyForSsh2(rsaPkcs8());
assert.equal(result.converted, true);
const parsed = parseOk(result.privateKey);
assert.ok(parsed, "converted key should be parseable by ssh2");
assert.equal(parsed.type, "ssh-rsa");
});
test("converts unencrypted EC PKCS#8 into an ssh2-parseable key", () => {
const result = normalizePrivateKeyForSsh2(ecPkcs8());
assert.equal(result.converted, true);
const parsed = parseOk(result.privateKey);
assert.ok(parsed);
assert.equal(parsed.type, "ecdsa-sha2-nistp256");
});
test("leaves an already-supported PKCS#1 key untouched", () => {
const key = rsaPkcs1();
const result = normalizePrivateKeyForSsh2(key);
assert.equal(result.converted, false);
assert.equal(result.privateKey, key);
});
test("decrypts and converts an encrypted RSA PKCS#8 key with the correct passphrase", () => {
const result = normalizePrivateKeyForSsh2(encryptedRsaPkcs8("secret"), "secret");
assert.equal(result.converted, true);
assert.equal(result.passphrase, undefined);
const parsed = parseOk(result.privateKey);
assert.ok(parsed);
assert.equal(parsed.type, "ssh-rsa");
});
test("throws PrivateKeyPassphraseError for encrypted PKCS#8 with a wrong passphrase", () => {
assert.throws(
() => normalizePrivateKeyForSsh2(encryptedRsaPkcs8("secret"), "wrong"),
(err) => err instanceof PrivateKeyPassphraseError,
);
});
test("throws UnsupportedPrivateKeyError for Ed25519 PKCS#8 with a conversion hint", () => {
assert.throws(
() => normalizePrivateKeyForSsh2(ed25519Pkcs8()),
(err) => err instanceof UnsupportedPrivateKeyError && /ssh-keygen/.test(err.message),
);
});
test("passes through content that is not a PKCS#8 key", () => {
const junk = "not a private key";
const result = normalizePrivateKeyForSsh2(junk);
assert.equal(result.converted, false);
assert.equal(result.privateKey, junk);
});
const indentLines = (key) =>
key.split("\n").map((line) => (line ? " " + line : line)).join("\n");
const dropEndLine = (key) => key.split("\n").slice(0, -2).join("\n");
const legacyEncryptedRsa = (passphrase) =>
crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs1", format: "pem", cipher: "aes-128-cbc", passphrase },
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
test("repairs an RSA key whose newlines were collapsed to spaces", () => {
const result = normalizePrivateKeyForSsh2(rsaPkcs1().replace(/\n/g, " "));
assert.equal(result.converted, true);
assert.ok(parseOk(result.privateKey), "repaired key should be parseable by ssh2");
});
test("repairs an RSA key whose newlines became literal backslash-n", () => {
const result = normalizePrivateKeyForSsh2(rsaPkcs1().replace(/\n/g, "\\n"));
assert.equal(result.converted, true);
assert.ok(parseOk(result.privateKey));
});
test("repairs an RSA key whose lines are indented", () => {
const result = normalizePrivateKeyForSsh2(indentLines(rsaPkcs1()));
assert.equal(result.converted, true);
assert.ok(parseOk(result.privateKey));
});
test("repairs and converts a collapsed PKCS#8 key", () => {
const result = normalizePrivateKeyForSsh2(rsaPkcs8().replace(/\n/g, " "));
assert.equal(result.converted, true);
const parsed = parseOk(result.privateKey);
assert.ok(parsed);
assert.equal(parsed.type, "ssh-rsa");
});
test("cannot repair a truncated key and leaves it unchanged", () => {
const truncated = dropEndLine(rsaPkcs1());
const result = normalizePrivateKeyForSsh2(truncated);
assert.equal(result.converted, false);
assert.equal(result.privateKey, truncated);
});
test("does not attempt to repair an encrypted legacy PEM (DEK-Info)", () => {
const collapsed = legacyEncryptedRsa("secret").replace(/\n/g, " ");
const result = normalizePrivateKeyForSsh2(collapsed, "secret");
assert.equal(result.converted, false);
});