Published source
Download source ZIP
CH-J Server Manager
Browse directories and files for a specific application release.
Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.
1
"use strict";3
const crypto = require("node:crypto");4
const fs = require("node:fs");5
const path = require("node:path");7
const TYPES = new Set(["ed25519", "rsa", "ecdsa", "dsa", "rsa1"]);8
const PRIVATE_KEY_FORMATS = new Set(["pem", "openssh", "ppk"]);9
const BITS = Object.freeze({ rsa: [2048, 3072, 4096], ecdsa: [256, 384, 521], dsa: [1024], rsa1: [1024] });10
const CURVES = Object.freeze({11
256: ["prime256v1", "ecdsa-sha2-nistp256", "nistp256", 32],12
384: ["secp384r1", "ecdsa-sha2-nistp384", "nistp384", 48],13
521: ["secp521r1", "ecdsa-sha2-nistp521", "nistp521", 66]14
});16
function decodeBase64Url(value) {17
const source = String(value || "").replace(/-/g, "+").replace(/_/g, "/");18
return Buffer.from(source.padEnd(Math.ceil(source.length / 4) * 4, "="), "base64");19
}21
function sshUint32(value) {22
const result = Buffer.alloc(4); result.writeUInt32BE(Number(value) >>> 0); return result;23
}25
function sshString(value) {26
const data = Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8");27
return Buffer.concat([sshUint32(data.length), data]);28
}30
function stripLeadingZeroBytes(value) {31
let data = Buffer.from(value || []);32
while (data.length > 1 && data[0] === 0) data = data.subarray(1);33
return data;34
}36
function sshMpint(value) {37
let data = stripLeadingZeroBytes(value);38
if (!data.length || data.every((byte) => byte === 0)) data = Buffer.alloc(0);39
else if (data[0] & 0x80) data = Buffer.concat([Buffer.from([0]), data]);40
return sshString(data);41
}43
function comment(value) {44
return String(value || "").replace(/[\r\n\t]+/g, " ").trim().slice(0, 200);45
}47
function normalize(payload = {}) {48
const type = String(payload.type || "ed25519").toLowerCase();49
if (!TYPES.has(type)) throw new Error("Unsupported key type.");50
if ((type === "dsa" || type === "rsa1") && payload.legacyConfirmed !== true) throw new Error("Legacy key generation requires explicit confirmation.");51
const allowed = BITS[type] || [];52
const defaults = { rsa: 4096, ecdsa: 256, dsa: 1024, rsa1: 1024 };53
const bits = allowed.includes(Number(payload.bits)) ? Number(payload.bits) : (defaults[type] || 0);54
const passphrase = String(payload.passphrase || "");55
if (passphrase.length > 1024 || passphrase.includes("\0")) throw new Error("Invalid key passphrase.");56
const requestedFormat = String(payload.privateKeyFormat || "pem").toLowerCase();57
if (!PRIVATE_KEY_FORMATS.has(requestedFormat)) throw new Error("Unsupported private key format.");58
return { type, bits, passphrase, privateKeyFormat: requestedFormat, comment: comment(payload.comment) };59
}61
function generatePair(type, bits) {62
if (type === "ed25519") return crypto.generateKeyPairSync("ed25519");63
if (type === "rsa" || type === "rsa1") return crypto.generateKeyPairSync("rsa", { modulusLength: bits, publicExponent: 0x10001 });64
if (type === "ecdsa") return crypto.generateKeyPairSync("ec", { namedCurve: CURVES[bits][0] });65
return crypto.generateKeyPairSync("dsa", { modulusLength: 1024, divisorLength: 160 });66
}68
function readDerLength(buffer, offset) {69
if (offset >= buffer.length) throw new Error("Invalid DER length.");70
const first = buffer[offset];71
if ((first & 0x80) === 0) return { length: first, nextOffset: offset + 1 };72
const bytes = first & 0x7f;73
if (!bytes || offset + 1 + bytes > buffer.length) throw new Error("Invalid DER length bytes.");74
let length = 0;75
for (let index = 0; index < bytes; index += 1) length = (length * 256) + buffer[offset + 1 + index];76
if (!Number.isSafeInteger(length)) throw new Error("Unsupported DER length size.");77
return { length, nextOffset: offset + 1 + bytes };78
}80
function readDerElement(buffer, offset = 0) {81
if (!Buffer.isBuffer(buffer) || offset < 0 || offset >= buffer.length) throw new Error("Invalid DER input.");82
const lengthInfo = readDerLength(buffer, offset + 1);83
const valueStart = lengthInfo.nextOffset; const valueEnd = valueStart + lengthInfo.length;84
if (valueEnd > buffer.length) throw new Error("Invalid DER element bounds.");85
return { tag: buffer[offset], valueStart, valueEnd, nextOffset: valueEnd };86
}88
function derInteger(buffer, element) {89
if (element.tag !== 0x02) throw new Error("Expected DER INTEGER.");90
return stripLeadingZeroBytes(buffer.subarray(element.valueStart, element.valueEnd));91
}93
function dsaParameters(buffer, algorithm) {94
let cursor = algorithm.valueStart;95
const oid = readDerElement(buffer, cursor); cursor = oid.nextOffset;96
const params = readDerElement(buffer, cursor);97
if (oid.tag !== 0x06 || params.tag !== 0x30 || params.nextOffset !== algorithm.valueEnd) throw new Error("Invalid DSA parameters.");98
cursor = params.valueStart;99
const p = readDerElement(buffer, cursor); cursor = p.nextOffset;100
const q = readDerElement(buffer, cursor); cursor = q.nextOffset;101
const g = readDerElement(buffer, cursor); cursor = g.nextOffset;102
if (cursor !== params.valueEnd) throw new Error("Invalid DSA parameter payload.");103
return { p: derInteger(buffer, p), q: derInteger(buffer, q), g: derInteger(buffer, g) };104
}106
function dsaPublicNumbers(publicKey) {107
const buffer = Buffer.from(publicKey.export({ type: "spki", format: "der" }));108
const root = readDerElement(buffer); let cursor = root.valueStart;109
const algorithm = readDerElement(buffer, cursor); cursor = algorithm.nextOffset;110
const subject = readDerElement(buffer, cursor);111
if (root.tag !== 0x30 || root.nextOffset !== buffer.length || algorithm.tag !== 0x30 || subject.tag !== 0x03 || subject.nextOffset !== root.valueEnd) throw new Error("Invalid DSA SPKI structure.");112
const encoded = buffer.subarray(subject.valueStart + 1, subject.valueEnd);113
const y = readDerElement(encoded);114
if (buffer[subject.valueStart] !== 0 || y.nextOffset !== encoded.length) throw new Error("Invalid DSA public value.");115
return { ...dsaParameters(buffer, algorithm), y: derInteger(encoded, y) };116
}118
function dsaPrivateNumbers(privateKey) {119
const buffer = Buffer.from(privateKey.export({ type: "pkcs8", format: "der" }));120
const root = readDerElement(buffer); let cursor = root.valueStart;121
const version = readDerElement(buffer, cursor); cursor = version.nextOffset;122
const algorithm = readDerElement(buffer, cursor); cursor = algorithm.nextOffset;123
const octet = readDerElement(buffer, cursor);124
if (root.tag !== 0x30 || root.nextOffset !== buffer.length || version.tag !== 0x02 || algorithm.tag !== 0x30 || octet.tag !== 0x04 || octet.nextOffset !== root.valueEnd) throw new Error("Invalid DSA PKCS#8 structure.");125
const encoded = buffer.subarray(octet.valueStart, octet.valueEnd); const x = readDerElement(encoded);126
if (x.nextOffset !== encoded.length) throw new Error("Invalid DSA private value.");127
return { ...dsaParameters(buffer, algorithm), x: derInteger(encoded, x) };128
}130
function keyNumbers(type, pair) {131
if (type === "dsa") return { ...dsaPublicNumbers(pair.publicKey), ...dsaPrivateNumbers(pair.privateKey) };132
const privateJwk = pair.privateKey.export({ format: "jwk" });133
const publicJwk = pair.publicKey.export({ format: "jwk" });134
if (type === "ed25519") return { seed: decodeBase64Url(privateJwk.d), publicRaw: decodeBase64Url(publicJwk.x) };135
if (type === "rsa" || type === "rsa1") return Object.fromEntries(["n", "e", "d", "p", "q", "qi"].map((key) => [key, stripLeadingZeroBytes(decodeBase64Url(privateJwk[key]))]));136
return { d: stripLeadingZeroBytes(decodeBase64Url(privateJwk.d)), x: decodeBase64Url(publicJwk.x), y: decodeBase64Url(publicJwk.y) };137
}139
function publicMaterial(type, bits, numbers, keyComment) {140
const suffix = keyComment ? ` ${keyComment}` : "";141
if (type === "ed25519") {142
const algorithm = "ssh-ed25519";143
const blob = Buffer.concat([sshString(algorithm), sshString(numbers.publicRaw)]);144
return { algorithm, blob, line: `${algorithm} ${blob.toString("base64")}${suffix}` };145
}146
if (type === "rsa" || type === "rsa1") {147
if (type === "rsa1") {148
const exponent = BigInt(`0x${numbers.e.toString("hex")}`).toString();149
const modulus = BigInt(`0x${numbers.n.toString("hex")}`).toString();150
return { algorithm: "ssh1-rsa", blob: Buffer.concat([sshMpint(numbers.e), sshMpint(numbers.n)]), line: `${bits} ${exponent} ${modulus}${suffix}` };151
}152
const algorithm = "ssh-rsa"; const blob = Buffer.concat([sshString(algorithm), sshMpint(numbers.e), sshMpint(numbers.n)]);153
return { algorithm, blob, line: `${algorithm} ${blob.toString("base64")}${suffix}` };154
}155
if (type === "dsa") {156
const algorithm = "ssh-dss";157
const blob = Buffer.concat([sshString(algorithm), sshMpint(numbers.p), sshMpint(numbers.q), sshMpint(numbers.g), sshMpint(numbers.y)]);158
return { algorithm, blob, line: `${algorithm} ${blob.toString("base64")}${suffix}` };159
}160
const [, algorithm, curve, size] = CURVES[bits];161
const x = numbers.x.length === size ? numbers.x : Buffer.concat([Buffer.alloc(size - numbers.x.length), numbers.x]);162
const y = numbers.y.length === size ? numbers.y : Buffer.concat([Buffer.alloc(size - numbers.y.length), numbers.y]);163
const point = Buffer.concat([Buffer.from([4]), x, y]);164
const blob = Buffer.concat([sshString(algorithm), sshString(curve), sshString(point)]);165
return { algorithm, curve, point, blob, line: `${algorithm} ${blob.toString("base64")}${suffix}` };166
}168
function pemBlock(name, raw) {169
const base64 = Buffer.from(raw).toString("base64"); const lines = [];170
for (let index = 0; index < base64.length; index += 70) lines.push(base64.slice(index, index + 70));171
return `-----BEGIN ${name}-----\n${lines.join("\n")}\n-----END ${name}-----\n`;172
}174
function openSshPrivate(type, numbers, material, keyComment) {175
const check = crypto.randomBytes(4); let fields;176
if (type === "ed25519") fields = [sshString(material.algorithm), sshString(numbers.publicRaw), sshString(Buffer.concat([numbers.seed, numbers.publicRaw]))];177
else if (type === "rsa") fields = [sshString(material.algorithm), sshMpint(numbers.n), sshMpint(numbers.e), sshMpint(numbers.d), sshMpint(numbers.qi), sshMpint(numbers.p), sshMpint(numbers.q)];178
else if (type === "dsa") fields = [sshString(material.algorithm), sshMpint(numbers.p), sshMpint(numbers.q), sshMpint(numbers.g), sshMpint(numbers.y), sshMpint(numbers.x)];179
else fields = [sshString(material.algorithm), sshString(material.curve), sshString(material.point), sshMpint(numbers.d)];180
let privateSection = Buffer.concat([check, check, ...fields, sshString(keyComment)]);181
const paddingLength = (8 - (privateSection.length % 8)) % 8;182
if (paddingLength) privateSection = Buffer.concat([privateSection, Buffer.from(Array.from({ length: paddingLength }, (_unused, index) => index + 1))]);183
const raw = Buffer.concat([184
Buffer.from("openssh-key-v1\0", "ascii"), sshString("none"), sshString("none"), sshString(Buffer.alloc(0)),185
sshUint32(1), sshString(material.blob), sshString(privateSection)186
]);187
return pemBlock("OPENSSH PRIVATE KEY", raw);188
}190
function ppkPrivateBlob(type, numbers) {191
if (type === "ed25519") return sshString(Buffer.concat([numbers.seed, numbers.publicRaw]));192
if (type === "rsa" || type === "rsa1") return Buffer.concat([sshMpint(numbers.d), sshMpint(numbers.p), sshMpint(numbers.q), sshMpint(numbers.qi)]);193
if (type === "dsa") return sshMpint(numbers.x);194
return sshMpint(numbers.d);195
}197
function ppkV2(type, material, numbers, keyComment) {198
const publicBlob = material.blob; const privateBlob = ppkPrivateBlob(type, numbers);199
const macData = Buffer.concat([sshString(material.algorithm), sshString("none"), sshString(keyComment), sshString(publicBlob), sshString(privateBlob)]);200
const macKey = crypto.createHash("sha1").update("putty-private-key-file-mac-key", "utf8").digest();201
const privateMac = crypto.createHmac("sha1", macKey).update(macData).digest("hex");202
const lines = (buffer) => buffer.toString("base64").match(/.{1,64}/g) || [""];203
const publicLines = lines(publicBlob); const privateLines = lines(privateBlob);204
return [`PuTTY-User-Key-File-2: ${material.algorithm}`, "Encryption: none", `Comment: ${keyComment}`, `Public-Lines: ${publicLines.length}`, ...publicLines, `Private-Lines: ${privateLines.length}`, ...privateLines, `Private-MAC: ${privateMac}`].join("\n") + "\n";205
}207
function exportPem(privateKey, passphrase) {208
const options = { type: "pkcs8", format: "pem" };209
if (passphrase) Object.assign(options, { cipher: "aes-256-cbc", passphrase });210
return String(privateKey.export(options));211
}213
function chooseFormat(input) {214
if (input.type === "rsa1") {215
if (input.passphrase) throw new Error("SSH1 RSA PPK export cannot be protected by a passphrase. Remove it or use a modern key type.");216
return { format: "ppk", notice: input.privateKeyFormat === "ppk" ? "SSH1 RSA je zastaralý formát." : "SSH1 RSA se ukládá jako PuTTY PPK v2." };217
}218
if (input.passphrase && input.privateKeyFormat !== "pem") {219
return { format: "pem", notice: `${input.privateKeyFormat === "ppk" ? "PPK v2" : "OpenSSH"} export s passphrase není ve vestavěném backendu dostupný; byl bezpečně použit šifrovaný PEM/PKCS#8.` };220
}221
return { format: input.privateKeyFormat, notice: input.privateKeyFormat === "ppk" ? "PPK v2 je určen hlavně pro kompatibilitu se staršími nástroji PuTTY." : "" };222
}224
class KeyGeneratorService {225
constructor(options = {}) {226
this.selectSavePath = options.selectSavePath;227
this.generations = new Map();228
}230
generate(payload) {231
const input = normalize(payload); const pair = generatePair(input.type, input.bits);232
const numbers = keyNumbers(input.type, pair); const material = publicMaterial(input.type, input.bits, numbers, input.comment);233
const privateKeyPem = exportPem(pair.privateKey, input.passphrase);234
const selected = chooseFormat(input);235
const privateKeyOpenSSH = selected.format === "openssh" ? openSshPrivate(input.type, numbers, material, input.comment) : "";236
const privateKeyPpk = selected.format === "ppk" ? ppkV2(input.type, material, numbers, input.comment) : "";237
const privateKey = selected.format === "openssh" ? privateKeyOpenSSH : selected.format === "ppk" ? privateKeyPpk : privateKeyPem;238
const generationId = crypto.randomUUID();239
const baseName = { ed25519: "id_ed25519", rsa: "id_rsa", ecdsa: "id_ecdsa", dsa: "id_dsa", rsa1: "id_rsa1" }[input.type];240
const suggestedName = selected.format === "ppk" ? `${baseName}.ppk` : baseName;241
const result = {242
generationId, type: input.type, bits: input.bits, privateKeyFormat: selected.format, privateKey,243
privateKeyPem, privateKeyOpenSSH, privateKeyPpk, publicKey: material.line, publicKeyOpenSsh: material.line,244
suggestedName, legacy: input.type === "dsa" || input.type === "rsa1", notice: selected.notice245
};246
this.generations.set(generationId, { ...result, createdAt: Date.now() });247
while (this.generations.size > 20) this.generations.delete(this.generations.keys().next().value);248
return { ...result };249
}251
async save(generationId) {252
const generated = this.generations.get(String(generationId || ""));253
if (!generated || Date.now() - generated.createdAt > 10 * 60 * 1000) throw new Error("Generated key expired. Generate it again.");254
if (typeof this.selectSavePath !== "function") throw new Error("Key save dialog is unavailable.");255
const selected = await this.selectSavePath(generated.suggestedName);256
if (!selected || selected.canceled || !selected.path) return { canceled: true };257
const privatePath = path.resolve(selected.path); const publicPath = `${privatePath}.pub`;258
await fs.promises.writeFile(privatePath, generated.privateKey, { encoding: "utf8", mode: 0o600 });259
await fs.promises.writeFile(publicPath, generated.publicKey, { encoding: "utf8", mode: 0o644 });260
await Promise.allSettled([fs.promises.chmod(privatePath, 0o600), fs.promises.chmod(publicPath, 0o644)]);261
return { canceled: false, privatePath, publicPath, privateKeyFormat: generated.privateKeyFormat };262
}263
}265
module.exports = { BITS, KeyGeneratorService, PRIVATE_KEY_FORMATS, normalize };SHA-256: dadc4718533314bc37d1226f5a1ab86d25b47265040681e074d0b5116973164d
Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0