smart-home-engine 1.2.0 → 1.4.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-CY1IzFyN.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-BW2J83t5.js";import{t as I}from"./index-AxQ546-B.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-CY1IzFyN.js"></script>
175
+ <script type="module" crossorigin src="/assets/index-AxQ546-B.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-CiR8cZ_Y.css">
178
+ <link rel="stylesheet" crossorigin href="/assets/index-Cp5N3a9H.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.2.0",
3
+ "version": "1.4.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
  /**
@@ -235,9 +333,18 @@ async function issueClientCert(config, { cn, days = 365 } = {}) {
235
333
  // Read serial from the signed cert
236
334
  const serial = await certSerial(crtPath);
237
335
 
238
- // Bundle to .p12
336
+ // Bundle to .p12 — include signing chain if ca-chain.crt exists
239
337
  const passphrase = crypto.randomBytes(10).toString('hex');
240
- await openssl(['pkcs12', '-export', '-in', crtPath, '-inkey', keyPath, '-certfile', caCrtPath, '-out', p12Path, '-passout', `pass:${passphrase}`, '-legacy']);
338
+ const chainPath = path.join(dir, 'ca-chain.crt');
339
+ let p12CertfileArg = caCrtPath;
340
+ let combinedChainTmp = null;
341
+ if (fs.existsSync(chainPath)) {
342
+ combinedChainTmp = path.join(dir, '.combined-chain-tmp.crt');
343
+ fs.writeFileSync(combinedChainTmp, fs.readFileSync(caCrtPath, 'utf8') + fs.readFileSync(chainPath, 'utf8'));
344
+ p12CertfileArg = combinedChainTmp;
345
+ }
346
+ await openssl(['pkcs12', '-export', '-in', crtPath, '-inkey', keyPath, '-certfile', p12CertfileArg, '-out', p12Path, '-passout', `pass:${passphrase}`, '-legacy']);
347
+ if (combinedChainTmp) try { fs.unlinkSync(combinedChainTmp); } catch { /* ignore */ }
241
348
 
242
349
  const fingerprint = await certFingerprint(crtPath);
243
350
  const expires = await certExpiry(crtPath);
@@ -460,6 +567,8 @@ module.exports = {
460
567
  caCertsDir,
461
568
  generateCA,
462
569
  getCA,
570
+ importCA,
571
+ extractFromP12,
463
572
  generateServerCert,
464
573
  issueClientCert,
465
574
  generateCRL,
package/src/lib/dynsec.js CHANGED
@@ -343,6 +343,16 @@ function setDefaultACLAccess(acls) {
343
343
  return _request('setDefaultACLAccess', { acls });
344
344
  }
345
345
 
346
+ // ── Anonymous group ────────────────────────────────────────────────────────────
347
+
348
+ function getAnonymousGroup() {
349
+ return _request('getAnonymousGroup').then((r) => r.data?.group ?? r.group ?? null);
350
+ }
351
+
352
+ function setAnonymousGroup(groupname) {
353
+ return _request('setAnonymousGroup', { groupname });
354
+ }
355
+
346
356
  module.exports = {
347
357
  init,
348
358
  getStatus,
@@ -376,4 +386,7 @@ module.exports = {
376
386
  // Default ACLs
377
387
  getDefaultACLAccess,
378
388
  setDefaultACLAccess,
389
+ // Anonymous group
390
+ getAnonymousGroup,
391
+ setAnonymousGroup,
379
392
  };
@@ -490,6 +490,42 @@ router.get('/acl-defaults', async (req, res) => {
490
490
  }
491
491
  });
492
492
 
493
+ /** PUT /she/broker/acl-defaults */
494
+ router.put('/acl-defaults', async (req, res) => {
495
+ try {
496
+ const { acls } = req.body;
497
+ if (!Array.isArray(acls)) return res.status(400).json({ error: 'acls array required' });
498
+ await dynsec.setDefaultACLAccess(acls);
499
+ res.json({ ok: true });
500
+ } catch (err) {
501
+ handleError(res, err);
502
+ }
503
+ });
504
+
505
+ // ── dynsec: Anonymous group ───────────────────────────────────────────────────
506
+
507
+ /** GET /she/broker/anonymous-group */
508
+ router.get('/anonymous-group', async (req, res) => {
509
+ try {
510
+ const group = await dynsec.getAnonymousGroup();
511
+ res.json({ group });
512
+ } catch (err) {
513
+ handleError(res, err);
514
+ }
515
+ });
516
+
517
+ /** PUT /she/broker/anonymous-group */
518
+ router.put('/anonymous-group', async (req, res) => {
519
+ try {
520
+ const { groupname } = req.body;
521
+ if (typeof groupname !== 'string' && groupname !== null) return res.status(400).json({ error: 'groupname string or null required' });
522
+ await dynsec.setAnonymousGroup(groupname);
523
+ res.json({ ok: true });
524
+ } catch (err) {
525
+ handleError(res, err);
526
+ }
527
+ });
528
+
493
529
  // ── CA routes ─────────────────────────────────────────────────────────────────
494
530
 
495
531
  /** GET /she/broker/ca — CA cert info */
@@ -515,6 +551,32 @@ router.post('/ca/generate', async (req, res) => {
515
551
  }
516
552
  });
517
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
+
518
580
  /** GET /she/broker/ca/server — Server cert info */
519
581
  router.get('/ca/server', async (req, res) => {
520
582
  try {