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.

10,0 KB · 288 linesDownload file
1"use strict";
3const crypto = require("node:crypto");
4const fs = require("node:fs");
5const path = require("node:path");
6const { promisify } = require("node:util");
7const { atomicWriteJson } = require("../storage/atomicFile");
9const scryptAsync = promisify(crypto.scrypt);
10const KEY_LENGTH = 32;
11const SALT_LENGTH = 16;
12const IV_LENGTH = 12;
13const AAD = Buffer.from("CHJ_CORE_VAULT_V1", "utf8");
14const SCRYPT = Object.freeze({ N: 1 << 15, r: 8, p: 1, maxmem: 128 * 1024 * 1024 });
15const MIN_PASSWORD_LENGTH = 4;
16const MAX_PASSWORD_LENGTH = 64;
18function clone(value) {
19 return JSON.parse(JSON.stringify(value));
22function initialVault() {
23 return {
24 schemaVersion: 1,
25 profiles: [],
26 hostKeys: {},
27 secrets: {}
28 };
31function validatePassword(password) {
32 const value = String(password || "");
33 if (value.includes("\0")) throw new Error("Password contains an invalid character.");
34 const length = Array.from(value).length;
35 if (length < MIN_PASSWORD_LENGTH || length > MAX_PASSWORD_LENGTH) {
36 throw new Error(`Vault password must contain ${MIN_PASSWORD_LENGTH} to ${MAX_PASSWORD_LENGTH} characters.`);
37 }
38 return value;
41function validateVaultData(value) {
42 if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Vault data is invalid.");
43 if (Number(value.schemaVersion) !== 1) throw new Error("Unsupported vault data schema.");
44 if (!Array.isArray(value.profiles)) throw new Error("Vault profiles are invalid.");
45 if (!value.hostKeys || typeof value.hostKeys !== "object" || Array.isArray(value.hostKeys)) throw new Error("Vault host keys are invalid.");
46 if (!value.secrets || typeof value.secrets !== "object" || Array.isArray(value.secrets)) value.secrets = {};
47 return value;
50class VaultStore {
51 constructor(rootDir) {
52 this.rootDir = path.join(rootDir, "vault");
53 this.metaPath = path.join(this.rootDir, "core-v1.meta.json");
54 this.dataPath = path.join(this.rootDir, "core-v1.data.json");
55 this.key = null;
56 this.data = null;
57 this.generation = 0;
58 }
60 status() {
61 const metaExists = fs.existsSync(this.metaPath);
62 const dataExists = fs.existsSync(this.dataPath);
63 const initialized = metaExists && dataExists;
64 return {
65 initialized,
66 needsSetup: !metaExists && !dataExists,
67 damaged: metaExists !== dataExists,
68 unlocked: Boolean(this.key && this.data)
69 };
70 }
72 async create(password) {
73 const generation = this.generation;
74 const status = this.status();
75 if (status.initialized) throw new Error("Vault is already initialized.");
76 if (status.damaged) throw new Error("Vault files are incomplete. Restore or remove them before setup.");
77 const normalized = validatePassword(password);
78 const salt = crypto.randomBytes(SALT_LENGTH);
79 const key = await this._deriveKey(normalized, salt);
80 if (generation !== this.generation || !this.status().needsSetup) { key.fill(0); throw new Error("Vault operation cancelled."); }
81 const data = initialVault();
82 const now = new Date().toISOString();
83 try {
84 atomicWriteJson(this.metaPath, {
85 formatVersion: 1,
86 dataSchemaVersion: 1,
87 createdAt: now,
88 updatedAt: now,
89 cipher: "aes-256-gcm",
90 kdf: { name: "scrypt", N: SCRYPT.N, r: SCRYPT.r, p: SCRYPT.p, keyLength: KEY_LENGTH },
91 salt: salt.toString("base64")
92 });
93 this._writeEncryptedData(data, key);
94 this._setSession(key, data);
95 return this.status();
96 } catch (error) {
97 key.fill(0);
98 try { fs.unlinkSync(this.metaPath); } catch {}
99 try { fs.unlinkSync(this.dataPath); } catch {}
100 throw error;
101 }
102 }
104 async unlock(password) {
105 const generation = this.generation, instance = this.instanceId();
106 if (!this.status().initialized) throw new Error("Vault setup is required or its files are incomplete.");
107 const normalized = validatePassword(password);
108 let key;
109 try {
110 const meta = JSON.parse(fs.readFileSync(this.metaPath, "utf8"));
111 if (meta.formatVersion !== 1 || meta.cipher !== "aes-256-gcm" || meta.kdf?.name !== "scrypt") {
112 throw new Error("Unsupported vault format.");
113 }
114 const salt = Buffer.from(String(meta.salt || ""), "base64");
115 if (salt.length !== SALT_LENGTH) throw new Error("Vault salt is invalid.");
116 key = await this._deriveKey(normalized, salt, meta.kdf);
117 if (generation !== this.generation || instance !== this.instanceId()) throw new Error("Vault operation cancelled.");
118 const data = this._readEncryptedData(key);
119 this._setSession(key, data);
120 return this.status();
121 } catch (error) {
122 key?.fill(0);
123 const failure = new Error("Vault password is incorrect or the vault is damaged.");
124 failure.code = "VAULT_UNLOCK_FAILED";
125 throw failure;
126 }
127 }
129 // Main-process-only key operations. No IPC route exposes these buffers.
130 instanceId() {
131 if (!this.status().initialized) return null;
132 const meta = JSON.parse(fs.readFileSync(this.metaPath, "utf8"));
133 return crypto.createHash("sha256").update(JSON.stringify([meta.formatVersion, meta.createdAt, meta.salt])).digest("hex");
134 }
136 async withUnlockKey(operation) {
137 this._assertUnlocked();
138 const copy = Buffer.from(this.key);
139 try { return await operation(copy); } finally { copy.fill(0); }
140 }
142 async verifyPassword(password) {
143 this._assertUnlocked();
144 const generation = this.generation;
145 const meta = JSON.parse(fs.readFileSync(this.metaPath, "utf8"));
146 const key = await this._deriveKey(validatePassword(password), Buffer.from(meta.salt, "base64"), meta.kdf);
147 try {
148 this._assertUnlocked();
149 if (generation !== this.generation) throw Object.assign(new Error("Master password required."), { code: "BIOMETRIC_MASTER_PASSWORD_REQUIRED" });
150 if (!crypto.timingSafeEqual(key, this.key)) throw Object.assign(new Error("Master password required."), { code: "BIOMETRIC_MASTER_PASSWORD_REQUIRED" });
151 this._readEncryptedData(key);
152 return true;
153 } finally { key.fill(0); }
154 }
156 unlockWithKey(key) {
157 if (!this.status().initialized || !Buffer.isBuffer(key) || key.length !== KEY_LENGTH) {
158 throw Object.assign(new Error("Invalid protected unlocking key."), { code: "BIOMETRIC_INVALID_KEY" });
159 }
160 const copy = Buffer.from(key);
161 try {
162 const data = this._readEncryptedData(copy); // AES-GCM authentication must succeed first.
163 this._setSession(copy, data);
164 return this.status();
165 } catch (_) {
166 copy.fill(0);
167 throw Object.assign(new Error("Biometric enrollment is no longer valid. Use the master password."), { code: "BIOMETRIC_ENROLLMENT_INVALIDATED" });
168 }
169 }
171 lock() {
172 this.generation++;
173 if (Buffer.isBuffer(this.key)) this.key.fill(0);
174 this.key = null;
175 this.data = null;
176 return this.status();
177 }
179 reset(confirmation) {
180 if (confirmation !== "SMAZAT") {
181 const error = new Error("Vault reset confirmation is invalid.");
182 error.code = "VAULT_RESET_CONFIRMATION_REQUIRED";
183 throw error;
184 }
185 this.lock();
186 fs.rmSync(this.rootDir, { recursive: true, force: true });
187 const status = this.status();
188 if (!status.needsSetup) {
189 const error = new Error("Vault data could not be removed completely.");
190 error.code = "VAULT_RESET_FAILED";
191 throw error;
192 }
193 return status;
194 }
196 getData() {
197 this._assertUnlocked();
198 return clone(this.data);
199 }
201 replaceData(nextData) {
202 this._assertUnlocked();
203 const normalized = validateVaultData(clone(nextData));
204 this._writeEncryptedData(normalized, this.key);
205 this.data = normalized;
206 return this.getData();
207 }
209 update(mutator) {
210 this._assertUnlocked();
211 const draft = this.getData();
212 const result = mutator(draft);
213 this.replaceData(draft);
214 return result;
215 }
217 _assertUnlocked() {
218 if (!this.key || !this.data) {
219 const error = new Error("Vault is locked.");
220 error.code = "VAULT_LOCKED";
221 throw error;
222 }
223 }
225 async _deriveKey(password, salt, params = SCRYPT) {
226 return Buffer.from(await scryptAsync(password, salt, KEY_LENGTH, {
227 N: Number(params.N || SCRYPT.N),
228 r: Number(params.r || SCRYPT.r),
229 p: Number(params.p || SCRYPT.p),
230 maxmem: SCRYPT.maxmem
231 }));
232 }
234 _writeEncryptedData(data, key) {
235 const plaintext = Buffer.from(JSON.stringify(validateVaultData(clone(data))), "utf8");
236 const iv = crypto.randomBytes(IV_LENGTH);
237 const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
238 cipher.setAAD(AAD);
239 const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
240 const authTag = cipher.getAuthTag();
241 try {
242 atomicWriteJson(this.dataPath, {
243 formatVersion: 1,
244 updatedAt: new Date().toISOString(),
245 iv: iv.toString("base64"),
246 authTag: authTag.toString("base64"),
247 ciphertext: ciphertext.toString("base64")
248 });
249 } finally {
250 plaintext.fill(0);
251 ciphertext.fill(0);
252 }
253 }
255 _readEncryptedData(key) {
256 const blob = JSON.parse(fs.readFileSync(this.dataPath, "utf8"));
257 if (blob.formatVersion !== 1) throw new Error("Unsupported encrypted vault data.");
258 const iv = Buffer.from(String(blob.iv || ""), "base64");
259 const authTag = Buffer.from(String(blob.authTag || ""), "base64");
260 const ciphertext = Buffer.from(String(blob.ciphertext || ""), "base64");
261 if (iv.length !== IV_LENGTH || authTag.length !== 16 || ciphertext.length === 0) throw new Error("Encrypted vault data is invalid.");
262 const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
263 decipher.setAAD(AAD);
264 decipher.setAuthTag(authTag);
265 const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
266 try {
267 return validateVaultData(JSON.parse(plaintext.toString("utf8")));
268 } finally {
269 plaintext.fill(0);
270 ciphertext.fill(0);
271 }
272 }
274 _setSession(key, data) {
275 this.lock();
276 this.key = key;
277 this.data = validateVaultData(clone(data));
278 }
281module.exports = {
282 MAX_PASSWORD_LENGTH,
283 MIN_PASSWORD_LENGTH,
284 VaultStore,
285 initialVault,
286 validatePassword,
287 validateVaultData
288};

SHA-256: ce8848f4e2c0a57c830bff42c2296d389d1cb1353812b5c53e264829e38d70b4

Archive SHA-256: 5ac91caf4fa32a6fdb114f2430deed486fbe7489d5eea343d1f034169fafb5e0