[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
198
electron/bridges/privateKeyNormalizer.cjs
Normal file
198
electron/bridges/privateKeyNormalizer.cjs
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user