* 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>
142 lines
5.1 KiB
JavaScript
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);
|
|
});
|