smart-home-engine 1.3.0 → 1.5.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-SmPJ7Vpm.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-BW2J83t5.js";import{t as I}from"./index-76-mgKLK.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -172,10 +172,10 @@
172
172
  }
173
173
  })();
174
174
  </script>
175
- <script type="module" crossorigin src="/assets/index-SmPJ7Vpm.js"></script>
175
+ <script type="module" crossorigin src="/assets/index-76-mgKLK.js"></script>
176
176
  <link rel="modulepreload" crossorigin href="/assets/monaco-langs-BW2J83t5.js">
177
177
  <link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
178
- <link rel="stylesheet" crossorigin href="/assets/index-BTdLG-cS.css">
178
+ <link rel="stylesheet" crossorigin href="/assets/index-CiYcuYTG.css">
179
179
  </head>
180
180
  <body>
181
181
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "1.3.0",
3
+ "version": "1.5.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/lib/ca.js CHANGED
@@ -104,18 +104,116 @@ async function generateCA(config, { cn = 'she-broker-ca', days = 365 } = {}) {
104
104
  async function getCA(config) {
105
105
  const dir = caDir(config);
106
106
  const crtPath = path.join(dir, 'ca.crt');
107
+ const chainPath = path.join(dir, 'ca-chain.crt');
107
108
  if (!fs.existsSync(crtPath)) return null;
108
109
  try {
109
110
  const fingerprint = await certFingerprint(crtPath);
110
111
  const expires = await certExpiry(crtPath);
111
112
  const cn = await certCN(crtPath);
112
113
  const crt = fs.readFileSync(crtPath, 'utf8');
113
- return { crt, fingerprint, expires, cn };
114
+ const hasChain = fs.existsSync(chainPath);
115
+ const chainCn = hasChain ? await certCN(chainPath).catch(() => null) : null;
116
+ return { crt, fingerprint, expires, cn, hasChain, chainCn };
114
117
  } catch {
115
118
  return null;
116
119
  }
117
120
  }
118
121
 
122
+ /**
123
+ * Import an existing CA keypair (PEM format) into the CA directory.
124
+ * Overwrites any existing ca.crt / ca.key.
125
+ * Optionally accepts a PEM chain (intermediate/root certs above this CA) stored
126
+ * as ca-chain.crt; used to build complete certificate chains in issued P12 bundles.
127
+ *
128
+ * @param {object} config
129
+ * @param {{ certPem: string, keyPem: string, chainPem?: string|null }} options
130
+ */
131
+ async function importCA(config, { certPem, keyPem, chainPem = null }) {
132
+ const dir = caDir(config);
133
+ fs.mkdirSync(dir, { recursive: true });
134
+
135
+ const tmpCert = path.join(dir, '_import_cert.tmp');
136
+ const tmpKey = path.join(dir, '_import_key.tmp');
137
+ fs.writeFileSync(tmpCert, certPem.trim() + '\n', 'utf8');
138
+ fs.writeFileSync(tmpKey, keyPem.trim() + '\n', 'utf8');
139
+
140
+ try {
141
+ await openssl(['x509', '-in', tmpCert, '-noout']);
142
+ await openssl(['pkey', '-in', tmpKey, '-noout']);
143
+ } catch (e) {
144
+ try { fs.unlinkSync(tmpCert); } catch { /* ignore */ }
145
+ try { fs.unlinkSync(tmpKey); } catch { /* ignore */ }
146
+ throw new Error(`Invalid certificate or key: ${e.message}`);
147
+ }
148
+
149
+ const keyPath = path.join(dir, 'ca.key');
150
+ const crtPath = path.join(dir, 'ca.crt');
151
+ const srlPath = path.join(dir, 'ca.srl');
152
+ const chainPath = path.join(dir, 'ca-chain.crt');
153
+
154
+ fs.renameSync(tmpCert, crtPath);
155
+ fs.renameSync(tmpKey, keyPath);
156
+ try { fs.chmodSync(keyPath, 0o600); } catch { /* ignore */ }
157
+
158
+ if (!fs.existsSync(srlPath)) {
159
+ fs.writeFileSync(srlPath, '01\n', 'utf8');
160
+ }
161
+
162
+ if (chainPem && chainPem.trim()) {
163
+ const tmpChain = path.join(dir, '_import_chain.tmp');
164
+ fs.writeFileSync(tmpChain, chainPem.trim() + '\n', 'utf8');
165
+ try {
166
+ await openssl(['x509', '-in', tmpChain, '-noout']);
167
+ } catch (e) {
168
+ try { fs.unlinkSync(tmpChain); } catch { /* ignore */ }
169
+ throw new Error(`Invalid chain certificate: ${e.message}`);
170
+ }
171
+ fs.renameSync(tmpChain, chainPath);
172
+ } else if (fs.existsSync(chainPath)) {
173
+ fs.unlinkSync(chainPath);
174
+ }
175
+
176
+ const fingerprint = await certFingerprint(crtPath);
177
+ const expires = await certExpiry(crtPath);
178
+ const cn = await certCN(crtPath);
179
+ const crt = fs.readFileSync(crtPath, 'utf8');
180
+ return { crt, fingerprint, expires, cn };
181
+ }
182
+
183
+ /**
184
+ * Extract certificate and private key PEM strings from a PKCS#12 file.
185
+ * Tries OpenSSL 3 defaults first, falls back to -legacy for older P12 files.
186
+ *
187
+ * @param {Buffer} p12Buffer raw .p12/.pfx file bytes
188
+ * @param {string} passphrase decryption passphrase (may be empty string)
189
+ * @returns {Promise<{ certPem: string, keyPem: string }>}
190
+ */
191
+ async function extractFromP12(p12Buffer, passphrase) {
192
+ const tmpP12 = path.join(os.tmpdir(), `she_p12_${Date.now()}.tmp`);
193
+ fs.writeFileSync(tmpP12, p12Buffer);
194
+ try {
195
+ const passArg = `pass:${passphrase}`;
196
+ const tryExtract = async (extraFlags = []) => {
197
+ const { stdout: certOut } = await openssl(['pkcs12', '-in', tmpP12, '-clcerts', '-nokeys', '-passin', passArg, '-nodes', ...extraFlags]);
198
+ const { stdout: keyOut } = await openssl(['pkcs12', '-in', tmpP12, '-nocerts', '-nodes', '-passin', passArg, ...extraFlags]);
199
+ return { certOut, keyOut };
200
+ };
201
+ let certOut, keyOut;
202
+ try {
203
+ ({ certOut, keyOut } = await tryExtract());
204
+ } catch {
205
+ ({ certOut, keyOut } = await tryExtract(['-legacy']));
206
+ }
207
+ const certMatch = certOut.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/);
208
+ const keyMatch = keyOut.match(/-----BEGIN [\w ]+ KEY-----[\s\S]+?-----END [\w ]+ KEY-----/);
209
+ if (!certMatch) throw new Error('No certificate found in P12 file');
210
+ if (!keyMatch) throw new Error('No private key found in P12 file');
211
+ return { certPem: certMatch[0] + '\n', keyPem: keyMatch[0] + '\n' };
212
+ } finally {
213
+ try { fs.unlinkSync(tmpP12); } catch { /* ignore */ }
214
+ }
215
+ }
216
+
119
217
  // ── Server certificate ─────────────────────────────────────────────────────────
120
218
 
121
219
  /**
@@ -130,7 +228,10 @@ async function generateServerCert(config, { cn, san = [], days = 365 } = {}) {
130
228
  const caCrtPath = path.join(dir, 'ca.crt');
131
229
  const srlPath = path.join(dir, 'ca.srl');
132
230
 
133
- if (!fs.existsSync(caCrtPath)) throw new Error('No CA found generate CA first');
231
+ // Auto-generate an internal CA if none exists (used for self-signed server certs)
232
+ if (!fs.existsSync(caCrtPath)) {
233
+ await generateCA(config, { cn: 'she-internal-ca', days: 3650 });
234
+ }
134
235
 
135
236
  const serverDir = path.join(dir, 'server');
136
237
  fs.mkdirSync(serverDir, { recursive: true });
@@ -190,6 +291,97 @@ async function generateServerCert(config, { cn, san = [], days = 365 } = {}) {
190
291
 
191
292
  // ── Client certificate issuance ────────────────────────────────────────────────
192
293
 
294
+ /**
295
+ * Generate a server private key and CSR without signing it.
296
+ * The key is stored on disk as server/server.key; the CSR is returned as PEM.
297
+ * The user takes the CSR to an external CA to get it signed, then calls
298
+ * importServerCert() to install the resulting certificate.
299
+ *
300
+ * @param {object} config
301
+ * @param {{ cn?: string, san?: string[], days?: number }} options (days ignored — for future use)
302
+ * @returns {Promise<{ csrPem: string }>}
303
+ */
304
+ async function generateServerCSR(config, { cn, san = [] } = {}) {
305
+ const dir = caDir(config);
306
+ const serverDir = path.join(dir, 'server');
307
+ fs.mkdirSync(serverDir, { recursive: true });
308
+
309
+ const keyPath = path.join(serverDir, 'server.key');
310
+ const csrPath = path.join(serverDir, 'server.csr');
311
+
312
+ // Generate key
313
+ await openssl(['genpkey', '-algorithm', 'ed25519', '-out', keyPath]);
314
+ try { fs.chmodSync(keyPath, 0o600); } catch { /* ignore */ }
315
+
316
+ // Build subject and optional SAN extension for the CSR
317
+ const sanEntries = [cn, ...san].filter(Boolean).map((s, i) => {
318
+ return /^\d+\.\d+\.\d+\.\d+$/.test(s) ? `IP.${i + 1}:${s}` : `DNS.${i + 1}:${s}`;
319
+ });
320
+ const subjArgs = ['-subj', `/CN=${cn || 'mosquitto'}`];
321
+ let reqArgs = ['req', '-new', '-key', keyPath, '-out', csrPath, ...subjArgs];
322
+ if (sanEntries.length) {
323
+ const extPath = path.join(serverDir, 'server-csr.conf');
324
+ fs.writeFileSync(extPath, `[req]\ndistinguished_name=dn\nreq_extensions=SAN\n[dn]\n[SAN]\nsubjectAltName=${sanEntries.join(',')}\n`, 'utf8');
325
+ reqArgs = ['req', '-new', '-key', keyPath, '-out', csrPath, ...subjArgs, '-config', extPath];
326
+ }
327
+ await openssl(reqArgs);
328
+
329
+ const csrPem = fs.readFileSync(csrPath, 'utf8');
330
+ return { csrPem };
331
+ }
332
+
333
+ /**
334
+ * Install an externally-signed certificate as the server certificate.
335
+ * If keyPem is provided it is written as server.key; otherwise the existing
336
+ * server.key on disk (e.g. from a prior generateServerCSR call) is used.
337
+ *
338
+ * @param {object} config
339
+ * @param {{ certPem: string, keyPem?: string|null }} options
340
+ * @returns {Promise<{ fingerprint: string, expires: string, cn: string }>}
341
+ */
342
+ async function importServerCert(config, { certPem, keyPem = null } = {}) {
343
+ const dir = caDir(config);
344
+ const serverDir = path.join(dir, 'server');
345
+ fs.mkdirSync(serverDir, { recursive: true });
346
+
347
+ const crtPath = path.join(serverDir, 'server.crt');
348
+ const keyPath = path.join(serverDir, 'server.key');
349
+
350
+ // Validate cert
351
+ const tmpCrt = crtPath + '.tmp';
352
+ fs.writeFileSync(tmpCrt, certPem.trim() + '\n', 'utf8');
353
+ try {
354
+ await openssl(['x509', '-in', tmpCrt, '-noout']);
355
+ } catch (e) {
356
+ try { fs.unlinkSync(tmpCrt); } catch { /* ignore */ }
357
+ throw new Error(`Invalid certificate: ${e.message}`);
358
+ }
359
+
360
+ if (keyPem) {
361
+ const tmpKey = keyPath + '.tmp';
362
+ fs.writeFileSync(tmpKey, keyPem.trim() + '\n', 'utf8');
363
+ try {
364
+ await openssl(['pkey', '-in', tmpKey, '-noout']);
365
+ } catch (e) {
366
+ try { fs.unlinkSync(tmpCrt); } catch { /* ignore */ }
367
+ try { fs.unlinkSync(tmpKey); } catch { /* ignore */ }
368
+ throw new Error(`Invalid private key: ${e.message}`);
369
+ }
370
+ fs.renameSync(tmpKey, keyPath);
371
+ try { fs.chmodSync(keyPath, 0o600); } catch { /* ignore */ }
372
+ } else if (!fs.existsSync(keyPath)) {
373
+ try { fs.unlinkSync(tmpCrt); } catch { /* ignore */ }
374
+ throw new Error('No private key provided and no existing server.key on disk');
375
+ }
376
+
377
+ fs.renameSync(tmpCrt, crtPath);
378
+
379
+ const fingerprint = await certFingerprint(crtPath);
380
+ const expires = await certExpiry(crtPath);
381
+ const cn = await certCN(crtPath);
382
+ return { fingerprint, expires, cn };
383
+ }
384
+
193
385
  /**
194
386
  * Issue a client certificate signed by the local CA.
195
387
  * Returns paths to .p12, .crt, .key and the p12 passphrase.
@@ -235,9 +427,18 @@ async function issueClientCert(config, { cn, days = 365 } = {}) {
235
427
  // Read serial from the signed cert
236
428
  const serial = await certSerial(crtPath);
237
429
 
238
- // Bundle to .p12
430
+ // Bundle to .p12 — include signing chain if ca-chain.crt exists
239
431
  const passphrase = crypto.randomBytes(10).toString('hex');
240
- await openssl(['pkcs12', '-export', '-in', crtPath, '-inkey', keyPath, '-certfile', caCrtPath, '-out', p12Path, '-passout', `pass:${passphrase}`, '-legacy']);
432
+ const chainPath = path.join(dir, 'ca-chain.crt');
433
+ let p12CertfileArg = caCrtPath;
434
+ let combinedChainTmp = null;
435
+ if (fs.existsSync(chainPath)) {
436
+ combinedChainTmp = path.join(dir, '.combined-chain-tmp.crt');
437
+ fs.writeFileSync(combinedChainTmp, fs.readFileSync(caCrtPath, 'utf8') + fs.readFileSync(chainPath, 'utf8'));
438
+ p12CertfileArg = combinedChainTmp;
439
+ }
440
+ await openssl(['pkcs12', '-export', '-in', crtPath, '-inkey', keyPath, '-certfile', p12CertfileArg, '-out', p12Path, '-passout', `pass:${passphrase}`, '-legacy']);
441
+ if (combinedChainTmp) try { fs.unlinkSync(combinedChainTmp); } catch { /* ignore */ }
241
442
 
242
443
  const fingerprint = await certFingerprint(crtPath);
243
444
  const expires = await certExpiry(crtPath);
@@ -460,7 +661,11 @@ module.exports = {
460
661
  caCertsDir,
461
662
  generateCA,
462
663
  getCA,
664
+ importCA,
665
+ extractFromP12,
463
666
  generateServerCert,
667
+ generateServerCSR,
668
+ importServerCert,
464
669
  issueClientCert,
465
670
  generateCRL,
466
671
  listTrustedCerts,
package/src/lib/dynsec.js CHANGED
@@ -346,7 +346,10 @@ function setDefaultACLAccess(acls) {
346
346
  // ── Anonymous group ────────────────────────────────────────────────────────────
347
347
 
348
348
  function getAnonymousGroup() {
349
- return _request('getAnonymousGroup').then((r) => r.data?.group ?? r.group ?? null);
349
+ return _request('getAnonymousGroup').then((r) => {
350
+ const g = r.data?.group ?? r.group ?? null;
351
+ return g?.groupname ?? g ?? null;
352
+ });
350
353
  }
351
354
 
352
355
  function setAnonymousGroup(groupname) {
@@ -551,6 +551,32 @@ router.post('/ca/generate', async (req, res) => {
551
551
  }
552
552
  });
553
553
 
554
+ /**
555
+ * POST /she/broker/ca/import
556
+ * Import an existing CA from PEM text or a PKCS#12 file.
557
+ * Body (PEM mode): { cert: string, key: string, chain?: string }
558
+ * Body (P12 mode): { p12base64: string, passphrase: string, chain?: string }
559
+ */
560
+ router.post('/ca/import', async (req, res) => {
561
+ try {
562
+ const bc = getBrokerConfig(req);
563
+ const { cert, key, chain, p12base64, passphrase } = req.body;
564
+ let certPem, keyPem;
565
+ if (p12base64 !== undefined) {
566
+ const p12Buffer = Buffer.from(p12base64, 'base64');
567
+ ({ certPem, keyPem } = await ca.extractFromP12(p12Buffer, passphrase ?? ''));
568
+ } else {
569
+ if (!cert || !key) return res.status(400).json({ error: 'cert and key are required' });
570
+ certPem = cert;
571
+ keyPem = key;
572
+ }
573
+ const result = await ca.importCA(bc, { certPem, keyPem, chainPem: chain || null });
574
+ res.json({ ok: true, ca: result });
575
+ } catch (err) {
576
+ handleError(res, err);
577
+ }
578
+ });
579
+
554
580
  /** GET /she/broker/ca/server — Server cert info */
555
581
  router.get('/ca/server', async (req, res) => {
556
582
  try {
@@ -567,7 +593,7 @@ router.get('/ca/server', async (req, res) => {
567
593
  }
568
594
  });
569
595
 
570
- /** POST /she/broker/ca/server/generate — Generate server cert */
596
+ /** POST /she/broker/ca/server/generate — Generate self-signed server cert (auto-creates internal CA) */
571
597
  router.post('/ca/server/generate', async (req, res) => {
572
598
  try {
573
599
  const bc = getBrokerConfig(req);
@@ -579,6 +605,50 @@ router.post('/ca/server/generate', async (req, res) => {
579
605
  }
580
606
  });
581
607
 
608
+ /**
609
+ * POST /she/broker/ca/server/csr
610
+ * Generate a private key + CSR for the server cert.
611
+ * The CSR PEM is returned for external signing; the key is kept on disk.
612
+ * Body: { cn?, san?: string[], days?: number }
613
+ */
614
+ router.post('/ca/server/csr', async (req, res) => {
615
+ try {
616
+ const bc = getBrokerConfig(req);
617
+ const { cn, san, days } = req.body;
618
+ const result = await ca.generateServerCSR(bc, { cn, san, days });
619
+ res.json({ ok: true, csrPem: result.csrPem });
620
+ } catch (err) {
621
+ handleError(res, err);
622
+ }
623
+ });
624
+
625
+ /**
626
+ * POST /she/broker/ca/server/import
627
+ * Install an externally-signed server certificate (PEM or PKCS#12).
628
+ * Body (PEM): { cert: string, key?: string }
629
+ * key is optional when a key already exists on disk (e.g. from /csr).
630
+ * Body (P12): { p12base64: string, passphrase?: string }
631
+ */
632
+ router.post('/ca/server/import', async (req, res) => {
633
+ try {
634
+ const bc = getBrokerConfig(req);
635
+ const { cert, key, p12base64, passphrase } = req.body;
636
+ let certPem, keyPem;
637
+ if (p12base64 !== undefined) {
638
+ const p12Buffer = Buffer.from(p12base64, 'base64');
639
+ ({ certPem, keyPem } = await ca.extractFromP12(p12Buffer, passphrase ?? ''));
640
+ } else {
641
+ if (!cert) return res.status(400).json({ error: 'cert is required' });
642
+ certPem = cert;
643
+ keyPem = key || null;
644
+ }
645
+ const result = await ca.importServerCert(bc, { certPem, keyPem });
646
+ res.json({ ok: true, server: result });
647
+ } catch (err) {
648
+ handleError(res, err);
649
+ }
650
+ });
651
+
582
652
  /** GET /she/broker/ca/certs — List issued client certs (from sheDB) */
583
653
  router.get('/ca/certs', async (req, res) => {
584
654
  try {