[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,543 @@
/**
* Convert PuTTY PPK private keys into unencrypted OpenSSH PEM.
*
* ssh2 only parses PPK v2 RSA/DSA (`PuTTY-User-Key-File-2: ssh-rsa|ssh-dss`).
* Encrypted Ed25519/ECDSA keys and all PPK v3 files therefore fail even with
* the correct passphrase. We decrypt the PPK ourselves and emit OpenSSH PEM
* that ssh2 already understands.
*
* Format: https://the.earth.li/~sgtatham/putty/0.83/htmldoc/AppendixC.html
*/
const {
createCipheriv,
createDecipheriv,
createHash,
createHmac,
randomBytes,
argon2Sync,
} = require("node:crypto");
const PPK_HEADER_RE = /^\s*PuTTY-User-Key-File-([23]):\s*(\S+)\s*$/m;
const AES_BLOCK = 16;
const AES_KEY_LEN = 32;
const PPK_V2_IV = Buffer.alloc(16, 0);
const PPK_V2_SEQ0 = Buffer.from([0, 0, 0, 0]);
const PPK_V2_SEQ1 = Buffer.from([0, 0, 0, 1]);
const PPK_V3_DERIVED_LEN = 80; // 32-byte key + 16-byte IV + 32-byte MAC key
const OPENSSH_MAGIC = Buffer.from("openssh-key-v1\0");
// Caps keep a crafted PPK from stalling the Electron main process. Defaults
// from current PuTTYgen (8 MiB, ~13-34 passes, parallelism 1) sit well inside.
const ARGON2_MIN_MEMORY_KIB = 8;
const ARGON2_MAX_MEMORY_KIB = 65536;
const ARGON2_MIN_PASSES = 1;
const ARGON2_MAX_PASSES = 128;
const ARGON2_MIN_PARALLELISM = 1;
const ARGON2_MAX_PARALLELISM = 4;
const ARGON2_MIN_SALT_BYTES = 8;
const ARGON2_MAX_SALT_BYTES = 64;
const SUPPORTED_TYPES = new Set([
"ssh-ed25519",
"ssh-rsa",
"ssh-dss",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
]);
function isPpkPrivateKey(text) {
return typeof text === "string" && PPK_HEADER_RE.test(text);
}
function passphraseBytes(passphrase) {
if (passphrase == null) return Buffer.alloc(0);
if (Buffer.isBuffer(passphrase)) return passphrase;
return Buffer.from(String(passphrase), "utf8");
}
function writeU32(value) {
const buf = Buffer.allocUnsafe(4);
buf.writeUInt32BE(value);
return buf;
}
function writeBytes(buf) {
return Buffer.concat([writeU32(buf.length), buf]);
}
function writeString(value) {
return writeBytes(Buffer.from(value, "utf8"));
}
function readString(buf, offset) {
if (offset + 4 > buf.length) return undefined;
const length = buf.readUInt32BE(offset);
const start = offset + 4;
const end = start + length;
if (end > buf.length) return undefined;
return { value: buf.subarray(start, end), next: end };
}
function readRequiredString(buf, offset, label) {
const field = readString(buf, offset);
if (!field) {
throw new Error(`Malformed PPK ${label}`);
}
return field;
}
function wrapBase64(buf, width = 64) {
const raw = buf.toString("base64");
const lines = [];
for (let i = 0; i < raw.length; i += width) {
lines.push(raw.slice(i, i + width));
}
return lines;
}
function parsePpk(text) {
const lines = String(text).replace(/^\uFEFF/, "").split(/\r?\n/);
while (lines.length && lines[0].trim() === "") lines.shift();
const header = PPK_HEADER_RE.exec(lines[0] || "");
if (!header) return null;
const version = Number(header[1]);
const type = header[2];
let index = 1;
const readHeader = (name) => {
const line = lines[index++];
if (line == null) {
throw new Error(`Truncated PPK: missing ${name}`);
}
const match = new RegExp(`^${name}:\\s*(.*)$`).exec(line);
if (!match) {
throw new Error(`Malformed PPK: expected ${name}`);
}
return match[1];
};
const readBase64Block = (count) => {
const chunk = lines.slice(index, index + count);
if (chunk.length !== count) {
throw new Error("Truncated PPK key data");
}
index += count;
return Buffer.from(chunk.join(""), "base64");
};
const encryption = readHeader("Encryption");
const comment = readHeader("Comment");
const publicLines = Number(readHeader("Public-Lines"));
if (!Number.isInteger(publicLines) || publicLines < 1) {
throw new Error("Malformed PPK public key");
}
const publicBlob = readBase64Block(publicLines);
let kdf;
if (version === 3 && encryption !== "none") {
kdf = {
name: readHeader("Key-Derivation"),
memory: Number(readHeader("Argon2-Memory")),
passes: Number(readHeader("Argon2-Passes")),
parallelism: Number(readHeader("Argon2-Parallelism")),
saltHex: readHeader("Argon2-Salt"),
};
}
const privateLines = Number(readHeader("Private-Lines"));
if (!Number.isInteger(privateLines) || privateLines < 1) {
throw new Error("Malformed PPK private key");
}
const privateBlob = readBase64Block(privateLines);
const mac = readHeader("Private-MAC").trim().toLowerCase();
return { version, type, encryption, comment, publicBlob, privateBlob, mac, kdf };
}
function deriveV2Keys(passphrase) {
const material = Buffer.concat([
createHash("sha1").update(PPK_V2_SEQ0).update(passphrase).digest(),
createHash("sha1").update(PPK_V2_SEQ1).update(passphrase).digest(),
]);
return {
cipherKey: material.subarray(0, AES_KEY_LEN),
iv: PPK_V2_IV,
macKey: createHash("sha1").update("putty-private-key-file-mac-key").update(passphrase).digest(),
macAlgo: "sha1",
};
}
function inRange(value, min, max) {
return Number.isInteger(value) && value >= min && value <= max;
}
function parseArgon2Salt(saltHex) {
if (typeof saltHex !== "string" || !/^[0-9a-fA-F]+$/.test(saltHex) || saltHex.length % 2 !== 0) {
throw new Error("Malformed PPK Argon2 salt");
}
const salt = Buffer.from(saltHex, "hex");
if (salt.length < ARGON2_MIN_SALT_BYTES || salt.length > ARGON2_MAX_SALT_BYTES) {
throw new Error("Malformed PPK Argon2 salt");
}
return salt;
}
function assertArgon2WorkFactors(kdf) {
if (!inRange(kdf.memory, ARGON2_MIN_MEMORY_KIB, ARGON2_MAX_MEMORY_KIB)
|| !inRange(kdf.passes, ARGON2_MIN_PASSES, ARGON2_MAX_PASSES)
|| !inRange(kdf.parallelism, ARGON2_MIN_PARALLELISM, ARGON2_MAX_PARALLELISM)) {
throw new Error(
`PPK Argon2 parameters exceed supported limits ` +
`(memory ${ARGON2_MIN_MEMORY_KIB}-${ARGON2_MAX_MEMORY_KIB} KiB, ` +
`passes ${ARGON2_MIN_PASSES}-${ARGON2_MAX_PASSES}, ` +
`parallelism ${ARGON2_MIN_PARALLELISM}-${ARGON2_MAX_PARALLELISM})`,
);
}
}
function deriveV3Keys(passphrase, encryption, kdf) {
if (encryption === "none") {
// Unencrypted PPK v3 uses HMAC-SHA256 with an empty key. Confirmed against
// PuTTYgen 0.85 and the SshAgentLib ed25519 v3-none fixture.
return { cipherKey: null, iv: null, macKey: Buffer.alloc(0), macAlgo: "sha256" };
}
const algorithm = String(kdf?.name || "").toLowerCase();
if (algorithm !== "argon2id" && algorithm !== "argon2i" && algorithm !== "argon2d") {
throw new Error(`Unsupported PPK key derivation "${kdf?.name}"`);
}
if (!kdf) {
throw new Error("Malformed PPK Argon2 parameters");
}
assertArgon2WorkFactors(kdf);
const salt = parseArgon2Salt(kdf.saltHex);
if (typeof argon2Sync !== "function") {
throw new Error("PPK_V3_ARGON2_UNAVAILABLE");
}
const derived = argon2Sync(algorithm, {
message: passphrase,
nonce: salt,
parallelism: kdf.parallelism,
tagLength: PPK_V3_DERIVED_LEN,
memory: kdf.memory,
passes: kdf.passes,
});
return {
cipherKey: derived.subarray(0, AES_KEY_LEN),
iv: derived.subarray(AES_KEY_LEN, AES_KEY_LEN + AES_BLOCK),
macKey: derived.subarray(AES_KEY_LEN + AES_BLOCK),
macAlgo: "sha256",
};
}
function aes256Cbc(blob, key, iv, decrypt) {
const fn = decrypt ? createDecipheriv : createCipheriv;
const cipher = fn("aes-256-cbc", key, iv);
cipher.setAutoPadding(false);
return Buffer.concat([cipher.update(blob), cipher.final()]);
}
function macPayload({ type, encryption, comment, publicBlob, privateBlob }) {
return Buffer.concat([
writeString(type),
writeString(encryption),
writeString(comment),
writeBytes(publicBlob),
writeBytes(privateBlob),
]);
}
function verifyMac(parsed, keys) {
const expected = createHmac(keys.macAlgo, keys.macKey)
.update(macPayload(parsed))
.digest("hex");
return expected === parsed.mac;
}
function buildOpenSshPrivate({ publicBlob, privateInner, comment }) {
const check = randomBytes(4);
let inner = Buffer.concat([
check,
check,
privateInner,
writeString(comment || ""),
]);
const padLen = (8 - (inner.length % 8)) % 8;
if (padLen) {
const pad = Buffer.allocUnsafe(padLen);
for (let i = 0; i < padLen; i++) pad[i] = i + 1;
inner = Buffer.concat([inner, pad]);
}
const blob = Buffer.concat([
OPENSSH_MAGIC,
writeString("none"),
writeString("none"),
writeBytes(Buffer.alloc(0)),
writeU32(1),
writeBytes(publicBlob),
writeBytes(inner),
]);
const body = wrapBase64(blob, 70).join("\n");
return `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
}
function ed25519PrivateInner(publicBlob, privateBlob) {
const pubType = readRequiredString(publicBlob, 0, "public key type");
const pubKey = readRequiredString(publicBlob, pubType.next, "public key");
const seedField = readRequiredString(privateBlob, 0, "Ed25519 seed");
if (pubKey.value.length !== 32 || seedField.value.length !== 32) {
throw new Error("Malformed PPK Ed25519 key");
}
return Buffer.concat([
writeString("ssh-ed25519"),
writeBytes(pubKey.value),
writeBytes(Buffer.concat([seedField.value, pubKey.value])),
]);
}
function rsaPrivateInner(publicBlob, privateBlob) {
const pubType = readRequiredString(publicBlob, 0, "public key type");
const e = readRequiredString(publicBlob, pubType.next, "RSA e");
const n = readRequiredString(publicBlob, e.next, "RSA n");
const d = readRequiredString(privateBlob, 0, "RSA d");
const p = readRequiredString(privateBlob, d.next, "RSA p");
const q = readRequiredString(privateBlob, p.next, "RSA q");
const iqmp = readRequiredString(privateBlob, q.next, "RSA iqmp");
return Buffer.concat([
writeString("ssh-rsa"),
writeBytes(n.value),
writeBytes(e.value),
writeBytes(d.value),
writeBytes(iqmp.value),
writeBytes(p.value),
writeBytes(q.value),
]);
}
function dssPrivateInner(publicBlob, privateBlob) {
const pubType = readRequiredString(publicBlob, 0, "public key type");
const p = readRequiredString(publicBlob, pubType.next, "DSA p");
const q = readRequiredString(publicBlob, p.next, "DSA q");
const g = readRequiredString(publicBlob, q.next, "DSA g");
const y = readRequiredString(publicBlob, g.next, "DSA y");
const x = readRequiredString(privateBlob, 0, "DSA x");
return Buffer.concat([
writeString("ssh-dss"),
writeBytes(p.value),
writeBytes(q.value),
writeBytes(g.value),
writeBytes(y.value),
writeBytes(x.value),
]);
}
function ecdsaPrivateInner(type, publicBlob, privateBlob) {
const curve = type.slice("ecdsa-sha2-".length);
const pubType = readRequiredString(publicBlob, 0, "public key type");
const curveName = readRequiredString(publicBlob, pubType.next, "ECDSA curve");
const point = readRequiredString(publicBlob, curveName.next, "ECDSA point");
const scalar = readRequiredString(privateBlob, 0, "ECDSA scalar");
return Buffer.concat([
writeString(type),
writeBytes(curveName.value.length ? curveName.value : Buffer.from(curve, "utf8")),
writeBytes(point.value),
writeBytes(scalar.value),
]);
}
function privateInnerForType(type, publicBlob, privateBlob) {
switch (type) {
case "ssh-ed25519":
return ed25519PrivateInner(publicBlob, privateBlob);
case "ssh-rsa":
return rsaPrivateInner(publicBlob, privateBlob);
case "ssh-dss":
return dssPrivateInner(publicBlob, privateBlob);
case "ecdsa-sha2-nistp256":
case "ecdsa-sha2-nistp384":
case "ecdsa-sha2-nistp521":
return ecdsaPrivateInner(type, publicBlob, privateBlob);
default:
throw new Error(`Unsupported PPK key type "${type}"`);
}
}
/**
* Convert a PPK private key to unencrypted OpenSSH PEM.
*
* @returns {{ privateKey: string, comment: string } | null}
* `null` when the text is not a PPK. Throws on decrypt / format errors.
*/
function convertPpkToOpenSsh(text, passphrase) {
let parsed;
try {
parsed = parsePpk(text);
} catch (err) {
if (!isPpkPrivateKey(text)) return null;
const fail = new Error(`Malformed PPK private key: ${err.message}`);
fail.code = "ERR_PPK_MALFORMED";
fail.cause = err;
throw fail;
}
if (!parsed) return null;
if (!SUPPORTED_TYPES.has(parsed.type)) {
const err = new Error(
`PuTTY keys of type "${parsed.type}" are not supported. ` +
"Export an OpenSSH key from PuTTYgen and try again.",
);
err.code = "ERR_PPK_UNSUPPORTED_TYPE";
throw err;
}
const encrypted = parsed.encryption !== "none";
if (encrypted && parsed.encryption !== "aes256-cbc") {
const err = new Error(`Unsupported PPK encryption "${parsed.encryption}"`);
err.code = "ERR_PPK_UNSUPPORTED_CIPHER";
throw err;
}
if (encrypted && (passphrase == null || passphraseBytes(passphrase).length === 0)) {
const err = new Error("Encrypted PPK private key detected, but no passphrase given");
err.code = "ERR_PPK_PASSPHRASE";
throw err;
}
const secret = passphraseBytes(encrypted ? passphrase : "");
let keys;
try {
keys = parsed.version === 3
? deriveV3Keys(secret, parsed.encryption, parsed.kdf)
: deriveV2Keys(secret);
} catch (err) {
if (err && err.message === "PPK_V3_ARGON2_UNAVAILABLE") {
const missing = new Error(
"Encrypted PuTTY PPK v3 keys require Argon2, which is unavailable in this runtime.",
);
missing.code = "ERR_PPK_UNSUPPORTED_KDF";
throw missing;
}
throw err;
}
let privateBlob = parsed.privateBlob;
if (encrypted) {
if (privateBlob.length % AES_BLOCK !== 0) {
const err = new Error("Malformed encrypted PPK private key");
err.code = "ERR_PPK_MALFORMED";
throw err;
}
try {
privateBlob = aes256Cbc(privateBlob, keys.cipherKey, keys.iv, true);
} catch (err) {
const fail = new Error("Could not decrypt the PPK private key with the provided passphrase");
fail.code = "ERR_PPK_PASSPHRASE";
fail.cause = err;
throw fail;
}
}
const decrypted = { ...parsed, privateBlob };
if (!verifyMac(decrypted, keys)) {
const err = new Error(
encrypted
? "PPK private key integrity check failed -- bad passphrase?"
: "PPK private key integrity check failed",
);
err.code = encrypted ? "ERR_PPK_PASSPHRASE" : "ERR_PPK_MALFORMED";
throw err;
}
const privateInner = privateInnerForType(parsed.type, parsed.publicBlob, privateBlob);
return {
privateKey: buildOpenSshPrivate({
publicBlob: parsed.publicBlob,
privateInner,
comment: parsed.comment,
}),
comment: parsed.comment,
};
}
// Test helper: serialize an already-assembled PPK (public/private SSH blobs).
function serializePpk({
version = 2,
type,
comment = "",
publicBlob,
privateBlob,
passphrase,
argon2 = {
name: "Argon2id",
memory: 8192,
passes: 2,
parallelism: 1,
},
}) {
const encryption = passphrase ? "aes256-cbc" : "none";
const secret = passphraseBytes(passphrase || "");
let kdf;
let keys;
let storedPrivate = privateBlob;
if (version === 3) {
if (encryption === "aes256-cbc") {
kdf = {
...argon2,
saltHex: randomBytes(16).toString("hex"),
};
}
keys = deriveV3Keys(secret, encryption, kdf);
} else {
keys = deriveV2Keys(secret);
}
if (encryption === "aes256-cbc") {
const padLen = (AES_BLOCK - (storedPrivate.length % AES_BLOCK)) % AES_BLOCK;
if (padLen) {
storedPrivate = Buffer.concat([storedPrivate, randomBytes(padLen)]);
}
}
const mac = createHmac(keys.macAlgo, keys.macKey)
.update(macPayload({ type, encryption, comment, publicBlob, privateBlob: storedPrivate }))
.digest("hex");
if (encryption === "aes256-cbc") {
storedPrivate = aes256Cbc(storedPrivate, keys.cipherKey, keys.iv, false);
}
const publicLines = wrapBase64(publicBlob);
const privateLines = wrapBase64(storedPrivate);
const lines = [
`PuTTY-User-Key-File-${version}: ${type}`,
`Encryption: ${encryption}`,
`Comment: ${comment}`,
`Public-Lines: ${publicLines.length}`,
...publicLines,
];
if (kdf) {
lines.push(
`Key-Derivation: ${kdf.name}`,
`Argon2-Memory: ${kdf.memory}`,
`Argon2-Passes: ${kdf.passes}`,
`Argon2-Parallelism: ${kdf.parallelism}`,
`Argon2-Salt: ${kdf.saltHex}`,
);
}
lines.push(
`Private-Lines: ${privateLines.length}`,
...privateLines,
`Private-MAC: ${mac}`,
"",
);
return lines.join("\n");
}
module.exports = {
isPpkPrivateKey,
convertPpkToOpenSsh,
serializePpk,
};