Files
NetMesh/electron/bridges/privateKeyNormalizer.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

199 lines
7.3 KiB
JavaScript

/**
* Private key normalizer.
*
* ssh2's key parser only understands OpenSSH, legacy PKCS#1/SEC1
* (`BEGIN RSA/DSA/EC PRIVATE KEY`) and PuTTY keys. It rejects PKCS#8
* (`-----BEGIN PRIVATE KEY-----` / `-----BEGIN ENCRYPTED PRIVATE KEY-----`)
* with "Unsupported key format", even though such keys are valid and accepted
* by other clients (e.g. Termius). See issue #1139.
*
* Node's crypto can read PKCS#8 and re-export RSA/EC keys in the legacy PEM
* forms ssh2 accepts, so we transparently convert them before handing the key
* to ssh2. Ed25519 (and other) PKCS#8 keys have no legacy PEM representation
* and surface a clear, actionable error instead of ssh2's opaque one.
*
* PuTTY PPK is similar: ssh2 only accepts v2 RSA/DSA, so encrypted Ed25519
* (and all PPK v3) keys are decrypted here and rewritten as OpenSSH PEM.
*/
const crypto = require("node:crypto");
const { utils: sshUtils } = require("ssh2");
const { convertPpkToOpenSsh, isPpkPrivateKey } = require("./ppkConverter.cjs");
const PKCS8_HEADER_RE = /-----BEGIN (?:ENCRYPTED )?PRIVATE KEY-----/;
// Node asymmetricKeyType -> legacy PEM export type that ssh2 can parse.
const LEGACY_EXPORT_TYPE = {
rsa: "pkcs1",
ec: "sec1",
};
class PrivateKeyPassphraseError extends Error {
constructor(message) {
super(message || "Incorrect passphrase for private key");
this.name = "PrivateKeyPassphraseError";
this.code = "ERR_PRIVATE_KEY_PASSPHRASE";
}
}
class UnsupportedPrivateKeyError extends Error {
constructor(message) {
super(message);
this.name = "UnsupportedPrivateKeyError";
this.code = "ERR_PRIVATE_KEY_UNSUPPORTED";
}
}
class InvalidPrivateKeyError extends Error {
constructor(message) {
super(message || "SSH key does not contain private key material");
this.name = "InvalidPrivateKeyError";
this.code = "ERR_PRIVATE_KEY_INVALID";
}
}
// 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`;
}
/**
* Return true when an ssh2 parser result contains private key material.
* parseKey() may return either one key object or an array of key objects.
*/
function hasPrivateKeyMaterial(parsed) {
const candidates = Array.isArray(parsed) ? parsed : [parsed];
return candidates.some((candidate) => {
if (!candidate || candidate instanceof Error) return false;
if (typeof candidate.isPrivateKey === "function" && candidate.isPrivateKey() === true) {
return true;
}
return typeof candidate.getPrivatePEM === "function"
&& candidate.getPrivatePEM() !== null;
});
}
/**
* Normalize a private key into a form ssh2 can parse.
*
* @param {string} privateKey - PEM private key contents.
* @param {string} [passphrase] - Passphrase, if the key is encrypted.
* @returns {{ privateKey: string, passphrase: string|undefined, converted: boolean }}
* @throws {PrivateKeyPassphraseError} Encrypted PKCS#8/PPK with a wrong/missing passphrase.
* @throws {UnsupportedPrivateKeyError} Key that cannot be converted into a form ssh2 accepts.
*/
function normalizePrivateKeyForSsh2(privateKey, passphrase) {
if (typeof privateKey !== "string" || privateKey.length === 0) {
return { privateKey, passphrase, converted: false };
}
// If ssh2 already understands the key and it contains private material,
// leave it exactly as-is. A public key can also parse successfully, but it
// must never be treated as a value for Client.connect({ privateKey }).
const parsed = sshUtils.parseKey(privateKey, passphrase);
if (hasPrivateKeyMaterial(parsed)) {
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 (hasPrivateKeyMaterial(reparsed)) {
return { privateKey: repaired, passphrase, converted: true };
}
}
const candidate = repaired || privateKey;
if (isPpkPrivateKey(candidate)) {
try {
const converted = convertPpkToOpenSsh(candidate, passphrase);
if (converted) {
return { privateKey: converted.privateKey, passphrase: undefined, converted: true };
}
} catch (err) {
if (err?.code === "ERR_PPK_PASSPHRASE") {
throw new PrivateKeyPassphraseError(
"Could not decrypt the PPK private key with the provided passphrase",
);
}
throw new UnsupportedPrivateKeyError(
err?.message || "Unable to convert the PuTTY PPK private key.",
);
}
}
// We can only rescue PKCS#8 keys, which Node's crypto can read.
if (!PKCS8_HEADER_RE.test(candidate)) {
return { privateKey, passphrase, converted: false };
}
const encrypted = candidate.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----");
let keyObject;
try {
keyObject = crypto.createPrivateKey(
passphrase ? { key: candidate, passphrase } : candidate,
);
} catch (err) {
if (encrypted) {
throw new PrivateKeyPassphraseError(
"Could not decrypt the PKCS#8 private key with the provided passphrase",
);
}
throw new UnsupportedPrivateKeyError(
`Unable to read the PKCS#8 private key: ${err.message}. ` +
"Convert it with `ssh-keygen -p -m PEM -f <key>` and try again.",
);
}
const exportType = LEGACY_EXPORT_TYPE[keyObject.asymmetricKeyType];
if (!exportType) {
throw new UnsupportedPrivateKeyError(
`Private keys of type "${keyObject.asymmetricKeyType}" in PKCS#8 format are not supported. ` +
"Convert it to OpenSSH format with `ssh-keygen -p -f <key>` and try again.",
);
}
const converted = keyObject.export({ type: exportType, format: "pem" }).toString();
return { privateKey: converted, passphrase: undefined, converted: true };
}
module.exports = {
normalizePrivateKeyForSsh2,
repairMalformedPem,
PrivateKeyPassphraseError,
UnsupportedPrivateKeyError,
InvalidPrivateKeyError,
hasPrivateKeyMaterial,
};