From aa8f8bfc117b0d9d92fc2bd539fa2616ab21d323 Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Fri, 29 May 2026 15:26:20 +0800 Subject: [PATCH 1/2] fix(ssh): repair mangled PEM private keys before parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- electron/bridges/privateKeyNormalizer.cjs | 50 +++++++++++++++++-- .../bridges/privateKeyNormalizer.test.cjs | 49 ++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/electron/bridges/privateKeyNormalizer.cjs b/electron/bridges/privateKeyNormalizer.cjs index 8cd4ca49..569e0bd9 100644 --- a/electron/bridges/privateKeyNormalizer.cjs +++ b/electron/bridges/privateKeyNormalizer.cjs @@ -40,6 +40,38 @@ class UnsupportedPrivateKeyError extends Error { } } +// Matches a private-key PEM block by its BEGIN/END markers (which survive even +// when the surrounding newlines are lost), capturing the label and raw body. +const PEM_BLOCK_RE = + /-----BEGIN ((?:RSA |DSA |EC |OPENSSH |ENCRYPTED )?PRIVATE KEY)-----([\s\S]*?)-----END \1-----/; + +/** + * Rebuild clean PEM framing for a key whose text was mangled in transit — + * newlines collapsed to spaces, turned into literal "\n", or lines indented. + * Returns the repaired PEM, or null when it isn't a recoverable block. + * + * The base64 body is preserved byte-for-byte (only non-base64 characters are + * stripped before re-wrapping), so this can never produce a different key. + * Encrypted legacy PEM (Proc-Type / DEK-Info header lines inside the body) is + * left alone — those lines aren't base64 and can't be safely re-wrapped. + */ +function repairMalformedPem(text) { + // Newlines flattened into literal "\n" / "\r\n" escape sequences. + const unescaped = text.replace(/\\r\\n|\\n|\\r/g, "\n"); + const match = PEM_BLOCK_RE.exec(unescaped); + if (!match) return null; + + const label = match[1]; + const body = match[2]; + if (/Proc-Type:|DEK-Info:/i.test(body)) return null; + + const base64 = body.replace(/[^A-Za-z0-9+/=]/g, ""); + if (!base64) return null; + + const wrapped = base64.replace(/.{1,64}/g, "$&\n").trimEnd(); + return `-----BEGIN ${label}-----\n${wrapped}\n-----END ${label}-----\n`; +} + /** * Normalize a private key into a form ssh2 can parse. * @@ -60,17 +92,29 @@ function normalizePrivateKeyForSsh2(privateKey, passphrase) { return { privateKey, passphrase, converted: false }; } + // The key text may have been mangled before it reached us — newlines lost, + // turned into literal "\n", or lines indented. Rebuild clean PEM framing and + // retry; a repaired key also feeds cleanly into the PKCS#8 path below. + const repaired = repairMalformedPem(privateKey); + if (repaired && repaired !== privateKey) { + const reparsed = sshUtils.parseKey(repaired, passphrase); + if (reparsed && !(reparsed instanceof Error)) { + return { privateKey: repaired, passphrase, converted: true }; + } + } + const candidate = repaired || privateKey; + // We can only rescue PKCS#8 keys, which Node's crypto can read. - if (!PKCS8_HEADER_RE.test(privateKey)) { + if (!PKCS8_HEADER_RE.test(candidate)) { return { privateKey, passphrase, converted: false }; } - const encrypted = privateKey.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----"); + const encrypted = candidate.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----"); let keyObject; try { keyObject = crypto.createPrivateKey( - passphrase ? { key: privateKey, passphrase } : privateKey, + passphrase ? { key: candidate, passphrase } : candidate, ); } catch (err) { if (encrypted) { diff --git a/electron/bridges/privateKeyNormalizer.test.cjs b/electron/bridges/privateKeyNormalizer.test.cjs index cf15cd5e..4b4bc2e9 100644 --- a/electron/bridges/privateKeyNormalizer.test.cjs +++ b/electron/bridges/privateKeyNormalizer.test.cjs @@ -90,3 +90,52 @@ test("passes through content that is not a PKCS#8 key", () => { 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); +}); From 81e02ca3a0e1cefdc852ea91ca8c11790bb0d9ac Mon Sep 17 00:00:00 2001 From: bincxz <16399091+binaricat@users.noreply.github.com> Date: Fri, 29 May 2026 15:42:02 +0800 Subject: [PATCH 2/2] fix(ssh): detect encryption on mangled OpenSSH keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- electron/bridges/privateKeyNormalizer.cjs | 1 + electron/bridges/sshAuthHelper.cjs | 6 ++- electron/bridges/sshAuthHelper.pkcs8.test.cjs | 42 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/electron/bridges/privateKeyNormalizer.cjs b/electron/bridges/privateKeyNormalizer.cjs index 569e0bd9..6d976601 100644 --- a/electron/bridges/privateKeyNormalizer.cjs +++ b/electron/bridges/privateKeyNormalizer.cjs @@ -142,6 +142,7 @@ function normalizePrivateKeyForSsh2(privateKey, passphrase) { module.exports = { normalizePrivateKeyForSsh2, + repairMalformedPem, PrivateKeyPassphraseError, UnsupportedPrivateKeyError, }; diff --git a/electron/bridges/sshAuthHelper.cjs b/electron/bridges/sshAuthHelper.cjs index 346703a0..66f2b462 100644 --- a/electron/bridges/sshAuthHelper.cjs +++ b/electron/bridges/sshAuthHelper.cjs @@ -12,6 +12,7 @@ const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs"); const passphraseHandler = require("./passphraseHandler.cjs"); const { normalizePrivateKeyForSsh2, + repairMalformedPem, PrivateKeyPassphraseError, } = require("./privateKeyNormalizer.cjs"); @@ -86,8 +87,11 @@ function isKeyEncrypted(keyContent) { // Check for OpenSSH format keys if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) { try { + // Repair mangled framing (lost or escaped newlines) first, so the cipher + // name can be read from the base64 blob even when the key was flattened. + const source = repairMalformedPem(keyContent) || keyContent; // Extract the base64 content between the markers - const base64Match = keyContent.match( + const base64Match = source.match( /-----BEGIN OPENSSH PRIVATE KEY-----\s*([\s\S]*?)\s*-----END OPENSSH PRIVATE KEY-----/ ); if (base64Match) { diff --git a/electron/bridges/sshAuthHelper.pkcs8.test.cjs b/electron/bridges/sshAuthHelper.pkcs8.test.cjs index 7a3db451..0641b03b 100644 --- a/electron/bridges/sshAuthHelper.pkcs8.test.cjs +++ b/electron/bridges/sshAuthHelper.pkcs8.test.cjs @@ -4,6 +4,7 @@ const crypto = require("node:crypto"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); +const { spawnSync } = require("node:child_process"); const { utils: sshUtils } = require("ssh2"); const { @@ -84,3 +85,44 @@ test("loadIdentityFileForAuth converts an unencrypted PKCS#8 identity file", asy assert.ok(result, "expected a loaded identity file"); assert.ok(isParseable(result.privateKey), "prepared key should be parseable by ssh2"); }); + +test("preparePrivateKeyForAuth recovers a mangled encrypted OpenSSH key via passphrase prompt", async (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mangled-openssh-")); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const keyPath = path.join(dir, "id_ed25519"); + const gen = spawnSync( + "ssh-keygen", + ["-q", "-t", "ed25519", "-N", "secret", "-f", keyPath, "-C", "netcatty-test"], + { encoding: "utf8" }, + ); + if (gen.status !== 0) { + t.skip("ssh-keygen is unavailable"); + return; + } + // Simulate a key whose line breaks were flattened into literal "\n" on paste. + const mangled = fs.readFileSync(keyPath, "utf8").replace(/\n/g, "\\n"); + + const originalRequest = passphraseHandler.requestPassphrase; + t.after(() => { + passphraseHandler.requestPassphrase = originalRequest; + }); + let prompts = 0; + passphraseHandler.requestPassphrase = async () => { + prompts += 1; + return { passphrase: "secret" }; + }; + + const result = await preparePrivateKeyForAuth({ + sender, + privateKey: mangled, + keyName: "id_ed25519", + hostname: "example.test", + logPrefix: "[Test]", + }); + + assert.ok(result, "expected a prepared key"); + assert.equal(prompts, 1, "the encrypted key should trigger exactly one passphrase prompt"); + assert.equal(result.passphrase, "secret"); + const parsed = sshUtils.parseKey(result.privateKey, result.passphrase); + assert.ok(parsed && !(parsed instanceof Error), "prepared key + passphrase should parse in ssh2"); +});