smart-home-engine 1.0.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-BW2J83t5.js";import{t as I}from"./index-BYavlZcf.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-BW2J83t5.js";import{t as I}from"./index-YxGnpZAh.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -141,10 +141,20 @@
141
141
  scrollbar-width: thin;
142
142
  scrollbar-color: var(--fg-dim) transparent;
143
143
  }
144
- *::-webkit-scrollbar { width: 6px; height: 6px; }
145
- *::-webkit-scrollbar-track { background: transparent; }
146
- *::-webkit-scrollbar-thumb { background: var(--fg-dim); border-radius: 3px; }
147
- *::-webkit-scrollbar-thumb:hover { background: var(--fg-muted); }
144
+ *::-webkit-scrollbar {
145
+ width: 6px;
146
+ height: 6px;
147
+ }
148
+ *::-webkit-scrollbar-track {
149
+ background: transparent;
150
+ }
151
+ *::-webkit-scrollbar-thumb {
152
+ background: var(--fg-dim);
153
+ border-radius: 3px;
154
+ }
155
+ *::-webkit-scrollbar-thumb:hover {
156
+ background: var(--fg-muted);
157
+ }
148
158
  body {
149
159
  font-family: 'Segoe UI', system-ui, sans-serif;
150
160
  background: var(--bg-app);
@@ -162,10 +172,10 @@
162
172
  }
163
173
  })();
164
174
  </script>
165
- <script type="module" crossorigin src="/assets/index-BYavlZcf.js"></script>
175
+ <script type="module" crossorigin src="/assets/index-YxGnpZAh.js"></script>
166
176
  <link rel="modulepreload" crossorigin href="/assets/monaco-langs-BW2J83t5.js">
167
177
  <link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
168
- <link rel="stylesheet" crossorigin href="/assets/index-B_L247qc.css">
178
+ <link rel="stylesheet" crossorigin href="/assets/index-DKIgEFlE.css">
169
179
  </head>
170
180
  <body>
171
181
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.0.9",
3
+ "version": "1.1.0",
4
4
  "description": "Node.js based script runner for use in MQTT based Smart Home environments",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/index.js CHANGED
@@ -504,6 +504,10 @@ if (config.matterStorage) {
504
504
  log.warn('matter controller disabled — set matterStorage in config.json to enable');
505
505
  }
506
506
 
507
+ // dynsec broker admin client - only init when broker.dynsec credentials are configured
508
+ if (config.broker && config.broker.dynsec && config.url) {
509
+ require('./lib/dynsec').init(config, log);
510
+ }
507
511
  // If no broker is configured, start scripts immediately.
508
512
  // If a broker is configured, startOnce() fires from the quiet-period timer inside
509
513
  // mqtt.on('connect') once the retained-message burst settles, or from the
package/src/lib/ca.js ADDED
@@ -0,0 +1,474 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ca.js — Local CA operations for she's broker certificate manager.
5
+ *
6
+ * All operations shell out to the system `openssl` binary (standard on
7
+ * Linux/macOS). No new Node.js dependencies required.
8
+ *
9
+ * Directory layout under caDir (default ~/.she/broker/ca/):
10
+ * ca.key — CA private key (Ed25519, chmod 600)
11
+ * ca.crt — CA self-signed certificate
12
+ * ca.srl — serial counter file
13
+ * crl.pem — certificate revocation list
14
+ * clients/ — issued client certs (per-CN subdirectory)
15
+ * <cn>/
16
+ * client.key
17
+ * client.crt
18
+ * client.p12
19
+ *
20
+ * CA cert metadata is stored in sheDB at broker::ca.
21
+ * Issued cert metadata is stored in sheDB at broker::cert::<serial>.
22
+ */
23
+
24
+ const { execFile } = require('child_process');
25
+ const { promisify } = require('util');
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const os = require('os');
29
+ const crypto = require('crypto');
30
+
31
+ const execFileAsync = promisify(execFile);
32
+
33
+ const OPENSSL = 'openssl';
34
+
35
+ // ── Path helpers ───────────────────────────────────────────────────────────────
36
+
37
+ function expandHome(p) {
38
+ if (p.startsWith('~/') || p === '~') {
39
+ return path.join(os.homedir(), p.slice(2));
40
+ }
41
+ return p;
42
+ }
43
+
44
+ function caDir(config) {
45
+ return expandHome((config.broker && config.broker.caDir) || '~/.she/broker/ca');
46
+ }
47
+
48
+ function caCertsDir(config) {
49
+ return expandHome((config.broker && config.broker.caCertsDir) || '~/.she/broker/ca-certs');
50
+ }
51
+
52
+ // ── openssl wrapper ────────────────────────────────────────────────────────────
53
+
54
+ async function openssl(args, options = {}) {
55
+ const { stdout, stderr } = await execFileAsync(OPENSSL, args, {
56
+ timeout: 30000,
57
+ ...options,
58
+ });
59
+ return { stdout, stderr };
60
+ }
61
+
62
+ // ── CA generation ──────────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * Generate a new local CA keypair + self-signed cert.
66
+ * @param {object} config
67
+ * @param {{ cn?: string, days?: number }} options
68
+ * @returns {Promise<{ crt: string, fingerprint: string, expires: string }>}
69
+ */
70
+ async function generateCA(config, { cn = 'she-broker-ca', days = 365 } = {}) {
71
+ const dir = caDir(config);
72
+ fs.mkdirSync(dir, { recursive: true });
73
+
74
+ const keyPath = path.join(dir, 'ca.key');
75
+ const crtPath = path.join(dir, 'ca.crt');
76
+ const srlPath = path.join(dir, 'ca.srl');
77
+
78
+ // Generate Ed25519 key + self-signed cert
79
+ await openssl(['req', '-x509', '-newkey', 'ed25519', '-keyout', keyPath, '-out', crtPath, '-days', String(days), '-nodes', '-subj', `/CN=${cn}`]);
80
+
81
+ // chmod 600 the private key
82
+ try {
83
+ fs.chmodSync(keyPath, 0o600);
84
+ } catch {
85
+ /* ignore on systems where this fails */
86
+ }
87
+
88
+ // Initialise serial file
89
+ if (!fs.existsSync(srlPath)) {
90
+ fs.writeFileSync(srlPath, '01\n', 'utf8');
91
+ }
92
+
93
+ const fingerprint = await certFingerprint(crtPath);
94
+ const expires = await certExpiry(crtPath);
95
+ const crt = fs.readFileSync(crtPath, 'utf8');
96
+
97
+ return { crt, fingerprint, expires, cn };
98
+ }
99
+
100
+ /**
101
+ * Get CA status. Returns null if no CA exists.
102
+ * @param {object} config
103
+ */
104
+ async function getCA(config) {
105
+ const dir = caDir(config);
106
+ const crtPath = path.join(dir, 'ca.crt');
107
+ if (!fs.existsSync(crtPath)) return null;
108
+ try {
109
+ const fingerprint = await certFingerprint(crtPath);
110
+ const expires = await certExpiry(crtPath);
111
+ const cn = await certCN(crtPath);
112
+ const crt = fs.readFileSync(crtPath, 'utf8');
113
+ return { crt, fingerprint, expires, cn };
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ // ── Server certificate ─────────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * Generate a server keypair + CSR + sign it with the local CA.
123
+ * @param {object} config
124
+ * @param {{ cn?: string, san?: string[], days?: number }} options
125
+ * @returns {Promise<{ crt: string, key: string, fingerprint: string, expires: string }>}
126
+ */
127
+ async function generateServerCert(config, { cn, san = [], days = 365 } = {}) {
128
+ const dir = caDir(config);
129
+ const caKeyPath = path.join(dir, 'ca.key');
130
+ const caCrtPath = path.join(dir, 'ca.crt');
131
+ const srlPath = path.join(dir, 'ca.srl');
132
+
133
+ if (!fs.existsSync(caCrtPath)) throw new Error('No CA found — generate CA first');
134
+
135
+ const serverDir = path.join(dir, 'server');
136
+ fs.mkdirSync(serverDir, { recursive: true });
137
+
138
+ const keyPath = path.join(serverDir, 'server.key');
139
+ const csrPath = path.join(serverDir, 'server.csr');
140
+ const crtPath = path.join(serverDir, 'server.crt');
141
+ const extPath = path.join(serverDir, 'server.ext');
142
+
143
+ // Build SAN extension file
144
+ const sanEntries = [cn, ...san].filter(Boolean).map((s, i) => {
145
+ return /^\d+\.\d+\.\d+\.\d+$/.test(s) ? `IP.${i + 1}:${s}` : `DNS.${i + 1}:${s}`;
146
+ });
147
+ const extContent = `[SAN]\nsubjectAltName=${sanEntries.join(',')}\n`;
148
+ fs.writeFileSync(extPath, extContent, 'utf8');
149
+
150
+ // Generate key
151
+ await openssl(['genpkey', '-algorithm', 'ed25519', '-out', keyPath]);
152
+ try {
153
+ fs.chmodSync(keyPath, 0o600);
154
+ } catch {
155
+ /* ignore */
156
+ }
157
+
158
+ // Generate CSR
159
+ await openssl(['req', '-new', '-key', keyPath, '-out', csrPath, '-subj', `/CN=${cn || 'mosquitto'}`]);
160
+
161
+ // Sign with CA
162
+ await openssl([
163
+ 'x509',
164
+ '-req',
165
+ '-in',
166
+ csrPath,
167
+ '-CA',
168
+ caCrtPath,
169
+ '-CAkey',
170
+ caKeyPath,
171
+ '-CAserial',
172
+ srlPath,
173
+ '-out',
174
+ crtPath,
175
+ '-days',
176
+ String(days),
177
+ '-extfile',
178
+ extPath,
179
+ '-extensions',
180
+ 'SAN',
181
+ ]);
182
+
183
+ const fingerprint = await certFingerprint(crtPath);
184
+ const expires = await certExpiry(crtPath);
185
+ const crt = fs.readFileSync(crtPath, 'utf8');
186
+ const key = fs.readFileSync(keyPath, 'utf8');
187
+
188
+ return { crt, key, fingerprint, expires, certPath: crtPath, keyPath };
189
+ }
190
+
191
+ // ── Client certificate issuance ────────────────────────────────────────────────
192
+
193
+ /**
194
+ * Issue a client certificate signed by the local CA.
195
+ * Returns paths to .p12, .crt, .key and the p12 passphrase.
196
+ *
197
+ * @param {object} config
198
+ * @param {{ cn: string, days?: number }} options
199
+ * @returns {Promise<{ serial: string, crt: string, key: string, p12Path: string, passphrase: string, fingerprint: string, expires: string }>}
200
+ */
201
+ async function issueClientCert(config, { cn, days = 365 } = {}) {
202
+ if (!cn) throw new Error('cn is required');
203
+
204
+ const dir = caDir(config);
205
+ const caKeyPath = path.join(dir, 'ca.key');
206
+ const caCrtPath = path.join(dir, 'ca.crt');
207
+ const srlPath = path.join(dir, 'ca.srl');
208
+
209
+ if (!fs.existsSync(caCrtPath)) throw new Error('No CA found — generate CA first');
210
+
211
+ // Sanitise CN for use as directory name
212
+ const safeCn = cn.replace(/[^a-zA-Z0-9._-]/g, '_');
213
+ const clientDir = path.join(dir, 'clients', safeCn);
214
+ fs.mkdirSync(clientDir, { recursive: true });
215
+
216
+ const keyPath = path.join(clientDir, 'client.key');
217
+ const csrPath = path.join(clientDir, 'client.csr');
218
+ const crtPath = path.join(clientDir, 'client.crt');
219
+ const p12Path = path.join(clientDir, 'client.p12');
220
+
221
+ // Generate client key
222
+ await openssl(['genpkey', '-algorithm', 'ed25519', '-out', keyPath]);
223
+ try {
224
+ fs.chmodSync(keyPath, 0o600);
225
+ } catch {
226
+ /* ignore */
227
+ }
228
+
229
+ // Generate CSR
230
+ await openssl(['req', '-new', '-key', keyPath, '-out', csrPath, '-subj', `/CN=${cn}`]);
231
+
232
+ // Sign with CA
233
+ await openssl(['x509', '-req', '-in', csrPath, '-CA', caCrtPath, '-CAkey', caKeyPath, '-CAserial', srlPath, '-out', crtPath, '-days', String(days)]);
234
+
235
+ // Read serial from the signed cert
236
+ const serial = await certSerial(crtPath);
237
+
238
+ // Bundle to .p12
239
+ const passphrase = crypto.randomBytes(10).toString('hex');
240
+ await openssl(['pkcs12', '-export', '-in', crtPath, '-inkey', keyPath, '-certfile', caCrtPath, '-out', p12Path, '-passout', `pass:${passphrase}`, '-legacy']);
241
+
242
+ const fingerprint = await certFingerprint(crtPath);
243
+ const expires = await certExpiry(crtPath);
244
+ const crt = fs.readFileSync(crtPath, 'utf8');
245
+ const key = fs.readFileSync(keyPath, 'utf8');
246
+
247
+ return {
248
+ serial,
249
+ cn,
250
+ crt,
251
+ key,
252
+ p12Path,
253
+ passphrase,
254
+ fingerprint,
255
+ expires,
256
+ issued: new Date().toISOString(),
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Regenerate CRL from a list of revoked cert files.
262
+ * @param {object} config
263
+ * @param {string[]} revokedCertPaths - PEM cert file paths to include in CRL
264
+ */
265
+ async function generateCRL(config, revokedCertPaths = []) {
266
+ const dir = caDir(config);
267
+ const caKeyPath = path.join(dir, 'ca.key');
268
+ const caCrtPath = path.join(dir, 'ca.crt');
269
+ const crlPath = path.join(dir, 'crl.pem');
270
+
271
+ if (!fs.existsSync(caCrtPath)) throw new Error('No CA found');
272
+
273
+ // Build a minimal openssl database for revocations
274
+ const dbDir = path.join(dir, '.crldb');
275
+ fs.mkdirSync(dbDir, { recursive: true });
276
+ const dbFile = path.join(dbDir, 'index.txt');
277
+ const dbAttr = path.join(dbDir, 'index.txt.attr');
278
+ const crlSrl = path.join(dbDir, 'crlnumber');
279
+ const confPath = path.join(dbDir, 'openssl.cnf');
280
+
281
+ // Initialise DB files if missing
282
+ if (!fs.existsSync(dbFile)) fs.writeFileSync(dbFile, '', 'utf8');
283
+ if (!fs.existsSync(dbAttr)) fs.writeFileSync(dbAttr, 'unique_subject = no\n', 'utf8');
284
+ if (!fs.existsSync(crlSrl)) fs.writeFileSync(crlSrl, '01\n', 'utf8');
285
+
286
+ const confContent = `
287
+ [ ca ]
288
+ default_ca = CA_default
289
+
290
+ [ CA_default ]
291
+ dir = ${dbDir}
292
+ database = ${dbFile}
293
+ new_certs_dir = ${dbDir}
294
+ certificate = ${caCrtPath}
295
+ private_key = ${caKeyPath}
296
+ default_md = default
297
+ default_crl_days = 30
298
+ crl_extensions = crl_ext
299
+ crlnumber = ${crlSrl}
300
+
301
+ [ crl_ext ]
302
+ authorityKeyIdentifier = keyid:always
303
+
304
+ [ req ]
305
+ default_bits = 2048
306
+ `;
307
+ fs.writeFileSync(confPath, confContent, 'utf8');
308
+
309
+ // Revoke each cert in the database
310
+ for (const certPath of revokedCertPaths) {
311
+ try {
312
+ await openssl(['ca', '-config', confPath, '-revoke', certPath, '-batch']);
313
+ } catch {
314
+ // cert may already be in the DB — ignore duplicate errors
315
+ }
316
+ }
317
+
318
+ // Generate CRL
319
+ await openssl(['ca', '-config', confPath, '-gencrl', '-out', crlPath, '-batch']);
320
+
321
+ return crlPath;
322
+ }
323
+
324
+ // ── Trusted CA certs (capath) ──────────────────────────────────────────────────
325
+
326
+ /**
327
+ * List all trusted CA certs in the capath directory.
328
+ * @param {object} config
329
+ * @returns {Promise<{ filename: string, cn: string, fingerprint: string, expires: string }[]>}
330
+ */
331
+ async function listTrustedCerts(config) {
332
+ const dir = caCertsDir(config);
333
+ let files;
334
+ try {
335
+ files = fs.readdirSync(dir).filter((f) => f.endsWith('.pem') || f.endsWith('.crt'));
336
+ } catch {
337
+ return [];
338
+ }
339
+
340
+ const result = [];
341
+ for (const file of files) {
342
+ const fp = path.join(dir, file);
343
+ try {
344
+ const cn = await certCN(fp);
345
+ const fingerprint = await certFingerprint(fp);
346
+ const expires = await certExpiry(fp);
347
+ result.push({ filename: file, cn, fingerprint, expires });
348
+ } catch {
349
+ result.push({ filename: file, cn: '?', fingerprint: '?', expires: '?' });
350
+ }
351
+ }
352
+ return result;
353
+ }
354
+
355
+ /**
356
+ * Add a trusted CA cert to the capath directory.
357
+ * @param {object} config
358
+ * @param {string} pemContent - PEM certificate text
359
+ * @returns {Promise<{ filename: string, fingerprint: string }>}
360
+ */
361
+ async function addTrustedCert(config, pemContent) {
362
+ const dir = caCertsDir(config);
363
+ fs.mkdirSync(dir, { recursive: true });
364
+
365
+ // Derive filename from fingerprint
366
+ const tmpPath = path.join(dir, `_tmp_${Date.now()}.pem`);
367
+ fs.writeFileSync(tmpPath, pemContent, 'utf8');
368
+ let fingerprint;
369
+ try {
370
+ fingerprint = await certFingerprint(tmpPath);
371
+ } catch (e) {
372
+ fs.unlinkSync(tmpPath);
373
+ throw new Error(`Invalid certificate PEM: ${e.message}`);
374
+ }
375
+
376
+ const filename = fingerprint.replace(/:/g, '').slice(0, 16) + '.pem';
377
+ const destPath = path.join(dir, filename);
378
+ fs.renameSync(tmpPath, destPath);
379
+
380
+ // Re-run openssl rehash so mosquitto can find this cert
381
+ try {
382
+ await openssl(['rehash', dir]);
383
+ } catch {
384
+ /* openssl rehash may not exist on all platforms — non-fatal */
385
+ }
386
+
387
+ return { filename, fingerprint };
388
+ }
389
+
390
+ /**
391
+ * Remove a trusted CA cert by its fingerprint.
392
+ * @param {object} config
393
+ * @param {string} fingerprint
394
+ */
395
+ async function removeTrustedCert(config, fingerprint) {
396
+ const dir = caCertsDir(config);
397
+ const certs = await listTrustedCerts(config);
398
+ const match = certs.find((c) => c.fingerprint === fingerprint);
399
+ if (!match) throw new Error('Cert not found');
400
+ fs.unlinkSync(path.join(dir, match.filename));
401
+
402
+ // Rehash after removal
403
+ try {
404
+ await openssl(['rehash', dir]);
405
+ } catch {
406
+ /* non-fatal */
407
+ }
408
+ }
409
+
410
+ // ── Client cert file helpers (for download) ────────────────────────────────────
411
+
412
+ /**
413
+ * Get file paths for an issued client cert by CN.
414
+ * @param {object} config
415
+ * @param {string} cn
416
+ * @returns {{ keyPath, crtPath, p12Path, caPath }}
417
+ */
418
+ function clientCertPaths(config, cn) {
419
+ const dir = caDir(config);
420
+ const safeCn = cn.replace(/[^a-zA-Z0-9._-]/g, '_');
421
+ const clientDir = path.join(dir, 'clients', safeCn);
422
+ return {
423
+ keyPath: path.join(clientDir, 'client.key'),
424
+ crtPath: path.join(clientDir, 'client.crt'),
425
+ p12Path: path.join(clientDir, 'client.p12'),
426
+ caPath: path.join(dir, 'ca.crt'),
427
+ serverCrtPath: path.join(dir, 'server', 'server.crt'),
428
+ serverKeyPath: path.join(dir, 'server', 'server.key'),
429
+ };
430
+ }
431
+
432
+ // ── openssl x509 parsing helpers ───────────────────────────────────────────────
433
+
434
+ async function certFingerprint(certPath) {
435
+ const { stdout } = await openssl(['x509', '-in', certPath, '-fingerprint', '-sha256', '-noout']);
436
+ const match = stdout.match(/SHA256 Fingerprint=([0-9A-F:]+)/i);
437
+ return match ? match[1] : stdout.trim();
438
+ }
439
+
440
+ async function certExpiry(certPath) {
441
+ const { stdout } = await openssl(['x509', '-in', certPath, '-enddate', '-noout']);
442
+ const match = stdout.match(/notAfter=(.+)/);
443
+ return match ? new Date(match[1]).toISOString() : stdout.trim();
444
+ }
445
+
446
+ async function certCN(certPath) {
447
+ const { stdout } = await openssl(['x509', '-in', certPath, '-subject', '-noout', '-nameopt', 'RFC2253']);
448
+ const match = stdout.match(/CN=([^,]+)/);
449
+ return match ? match[1].trim() : stdout.trim();
450
+ }
451
+
452
+ async function certSerial(certPath) {
453
+ const { stdout } = await openssl(['x509', '-in', certPath, '-serial', '-noout']);
454
+ const match = stdout.match(/serial=([0-9A-Fa-f]+)/);
455
+ return match ? match[1] : stdout.trim();
456
+ }
457
+
458
+ module.exports = {
459
+ caDir,
460
+ caCertsDir,
461
+ generateCA,
462
+ getCA,
463
+ generateServerCert,
464
+ issueClientCert,
465
+ generateCRL,
466
+ listTrustedCerts,
467
+ addTrustedCert,
468
+ removeTrustedCert,
469
+ clientCertPaths,
470
+ certFingerprint,
471
+ certExpiry,
472
+ certCN,
473
+ certSerial,
474
+ };