CH-J Server Managerserver management over SSH
Menu
Published source

CH-J Server Manager

Browse directories and files for a specific application release.

Download source ZIP
CH-J Proprietary Software License 1.14

Source is provided under the CH-J Proprietary Software License 1.14. Its availability does not change the license terms or grant additional rights.

15,6 KB · 265 linesDownload file
1"use strict";
3const crypto = require("node:crypto");
4const fs = require("node:fs");
5const path = require("node:path");
7const TYPES = new Set(["ed25519", "rsa", "ecdsa", "dsa", "rsa1"]);
8const PRIVATE_KEY_FORMATS = new Set(["pem", "openssh", "ppk"]);
9const BITS = Object.freeze({ rsa: [2048, 3072, 4096], ecdsa: [256, 384, 521], dsa: [1024], rsa1: [1024] });
10const 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});
16function decodeBase64Url(value) {
17 const source = String(value || "").replace(/-/g, "+").replace(/_/g, "/");
18 return Buffer.from(source.padEnd(Math.ceil(source.length / 4) * 4, "="), "base64");
21function sshUint32(value) {
22 const result = Buffer.alloc(4); result.writeUInt32BE(Number(value) >>> 0); return result;
25function sshString(value) {
26 const data = Buffer.isBuffer(value) ? value : Buffer.from(String(value), "utf8");
27 return Buffer.concat([sshUint32(data.length), data]);
30function stripLeadingZeroBytes(value) {
31 let data = Buffer.from(value || []);
32 while (data.length > 1 && data[0] === 0) data = data.subarray(1);
33 return data;
36function 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);
43function comment(value) {
44 return String(value || "").replace(/[\r\n\t]+/g, " ").trim().slice(0, 200);
47function 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) };
61function 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 });
68function 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 };
80function 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 };
88function derInteger(buffer, element) {
89 if (element.tag !== 0x02) throw new Error("Expected DER INTEGER.");
90 return stripLeadingZeroBytes(buffer.subarray(element.valueStart, element.valueEnd));
93function 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) };
106function 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) };
118function 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) };
130function 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) };
139function 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}` };
168function 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`;
174function 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);
190function 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);
197function 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";
207function 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));
213function 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." : "" };
224class 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.notice
245 };
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 }
265module.exports = { BITS, KeyGeneratorService, PRIVATE_KEY_FORMATS, normalize };

SHA-256: dadc4718533314bc37d1226f5a1ab86d25b47265040681e074d0b5116973164d

Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0