@camstack/system 1.2.438 → 1.2.439

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.
Files changed (92) hide show
  1. package/dist/{agent-https-BZIwS4xF.js → agent-https-DlpTMDqA.js} +252 -22
  2. package/dist/{agent-https-BPfHrI4c.mjs → agent-https-U0d4uvn2.mjs} +211 -23
  3. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  4. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  5. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/alerts/alerts.addon.js +1 -1
  8. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  9. package/dist/builtins/autotrack/index.js +1 -1
  10. package/dist/builtins/autotrack/index.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +2 -2
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +2 -2
  13. package/dist/builtins/calendars/calendars.addon.js +1 -1
  14. package/dist/builtins/calendars/calendars.addon.mjs +1 -1
  15. package/dist/builtins/camera-grid/index.js +1 -1
  16. package/dist/builtins/camera-grid/index.mjs +1 -1
  17. package/dist/builtins/composer/composer.addon.js +2 -2
  18. package/dist/builtins/composer/composer.addon.mjs +2 -2
  19. package/dist/builtins/console-logging/index.js +1 -1
  20. package/dist/builtins/console-logging/index.mjs +1 -1
  21. package/dist/builtins/core-blocks/core-blocks.addon.js +2 -2
  22. package/dist/builtins/core-blocks/core-blocks.addon.mjs +2 -2
  23. package/dist/builtins/device-manager/device-manager.addon.js +18 -3
  24. package/dist/builtins/device-manager/device-manager.addon.mjs +18 -3
  25. package/dist/builtins/device-manager/device-tombstone-store.d.ts +12 -2
  26. package/dist/builtins/hub-forwarder/index.js +1 -1
  27. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  28. package/dist/builtins/keychain/index.js +3 -3
  29. package/dist/builtins/keychain/index.mjs +3 -3
  30. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  31. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  32. package/dist/builtins/local-auth/local-auth.addon.js +2 -2
  33. package/dist/builtins/local-auth/local-auth.addon.mjs +2 -2
  34. package/dist/builtins/local-network/local-network.addon.js +7 -2
  35. package/dist/builtins/local-network/local-network.addon.mjs +7 -2
  36. package/dist/builtins/loki-logging/index.js +1 -1
  37. package/dist/builtins/loki-logging/index.mjs +1 -1
  38. package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
  39. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
  40. package/dist/builtins/platform-probe/index.js +1 -1
  41. package/dist/builtins/platform-probe/index.mjs +1 -1
  42. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  43. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  44. package/dist/builtins/snapshot/index.js +1 -1
  45. package/dist/builtins/snapshot/index.mjs +1 -1
  46. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  47. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  48. package/dist/builtins/sqlite-storage/index.js +1 -1
  49. package/dist/builtins/sqlite-storage/index.mjs +1 -1
  50. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +3 -3
  51. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +3 -3
  52. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  53. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  54. package/dist/builtins/system-config/system-config.addon.js +4 -4
  55. package/dist/builtins/system-config/system-config.addon.mjs +4 -4
  56. package/dist/builtins/winston-logging/index.js +1 -1
  57. package/dist/builtins/winston-logging/index.mjs +1 -1
  58. package/dist/{composition-sources-DI3Z83vG.js → composition-sources-B4jC2FC7.js} +1 -1
  59. package/dist/{composition-sources-DpHGCjdL.mjs → composition-sources-BGOSCGWa.mjs} +1 -1
  60. package/dist/{constants-DQK705Sb.mjs → constants-B92BjObl.mjs} +1 -1
  61. package/dist/{constants-wkcn5Ij3.js → constants-BRsjQwi-.js} +1 -1
  62. package/dist/{device-ca-DvIZjXIl.mjs → device-ca-46BUsWD1.mjs} +2 -2
  63. package/dist/{device-ca-BSvkXqyI.js → device-ca-DHG9OW2m.js} +2 -2
  64. package/dist/{dist-GgG-7amw.js → dist-BOWdLjoK.js} +34 -7
  65. package/dist/{dist-Dc8GKC5H.mjs → dist-yI6kF6r6.mjs} +34 -7
  66. package/dist/file-host-worker.js +1 -1
  67. package/dist/file-host-worker.mjs +1 -1
  68. package/dist/{hub-mdns-ciao-CBLvq2oj.js → hub-mdns-ciao-D7S5AJRU.js} +377 -69
  69. package/dist/{hub-mdns-ciao-DMlQTdPU.mjs → hub-mdns-ciao-DGwVc2uh.mjs} +344 -72
  70. package/dist/index.js +42 -9
  71. package/dist/index.mjs +30 -10
  72. package/dist/{lib-NNxHSOIE.js → lib-8oZrELLm.js} +1 -1
  73. package/dist/{lib-BeGzVQcd.mjs → lib-DK38ompL.mjs} +1 -1
  74. package/dist/{restore-archive-DPg9duVY.mjs → restore-archive-CpdUO-uS.mjs} +1 -1
  75. package/dist/{restore-archive-CTRz8v9o.js → restore-archive-DiGiI6Jp.js} +1 -1
  76. package/dist/{retired-settings-keys-Dr_4lfKu.js → retired-settings-keys-CyQ7uvta.js} +1 -1
  77. package/dist/{retired-settings-keys-Is0zO8gX.mjs → retired-settings-keys-UsVtLOT0.mjs} +1 -1
  78. package/dist/tls/agent-https.d.ts +2 -2
  79. package/dist/tls/boot-tls.d.ts +6 -0
  80. package/dist/tls/cert-evaluation.d.ts +1 -1
  81. package/dist/tls/cert-issue.d.ts +5 -1
  82. package/dist/tls/cert-manager.d.ts +18 -0
  83. package/dist/tls/cert-name-constraints.d.ts +53 -0
  84. package/dist/tls/index.d.ts +5 -4
  85. package/dist/tls/tls-actions.d.ts +27 -7
  86. package/dist/tls/tls-status.d.ts +22 -0
  87. package/dist/tls/uploaded-cert.d.ts +36 -4
  88. package/dist/{wal-maintenance-mjB2i-VR.js → wal-maintenance-DEL_jeG0.js} +0 -0
  89. package/dist/{wal-maintenance-Bx3lcVA7.mjs → wal-maintenance-xsvdT5Go.mjs} +0 -0
  90. package/dist/{x509.es-r7Ht1thA.js → x509.es-C4cOo48M.js} +36 -0
  91. package/dist/{x509.es-CDvj67p1.mjs → x509.es-DK__ETIe.mjs} +1 -1
  92. package/package.json +3 -1
@@ -1,10 +1,13 @@
1
1
  const require_chunk = require("./chunk-Cek0wNdY.js");
2
- const require_agent_https = require("./agent-https-BZIwS4xF.js");
2
+ const require_dist = require("./dist-BOWdLjoK.js");
3
+ const require_x509_es = require("./x509.es-C4cOo48M.js");
4
+ const require_agent_https = require("./agent-https-DlpTMDqA.js");
3
5
  let node_crypto = require("node:crypto");
4
6
  let _camstack_types_node = require("@camstack/types/node");
5
7
  let node_fs_promises = require("node:fs/promises");
6
8
  let node_path = require("node:path");
7
9
  let node_fs = require("node:fs");
10
+ let node_tls = require("node:tls");
8
11
  let node_http = require("node:http");
9
12
  //#region src/http/lan-http-bind.ts
10
13
  /**
@@ -188,24 +191,26 @@ async function ensureTlsCert(dataDir, options) {
188
191
  };
189
192
  const commonName = options?.commonName ?? DEFAULT_COMMON_NAME;
190
193
  const validDays = Math.min(options?.validDays ?? 397, 397);
191
- const identity = require_agent_https.collectCertIdentity({
194
+ const rawIdentity = require_agent_https.collectCertIdentity({
192
195
  commonName,
193
196
  extraSans: options?.extraSans ?? [],
194
197
  ...options?.hostFacts === void 0 ? {} : { hostFacts: options.hostFacts }
195
198
  });
196
- const reason = decideRegeneration(paths, identity);
197
- if (reason === null) return describe(paths, false, null, false, null, null);
199
+ const onDisk = constrainToDiskCa(paths, rawIdentity);
200
+ const reason = decideRegeneration(paths, onDisk.identity);
201
+ if (reason === null) return describe(paths, false, null, false, null, null, onDisk.dropped);
198
202
  const previousFingerprint = readFingerprint(paths.certPath);
199
203
  const caRotated = require_agent_https.reasonRequiresNewCa(reason);
200
204
  (0, node_fs.mkdirSync)(tlsDir, { recursive: true });
205
+ let dropped = [];
201
206
  try {
202
- await regenerate(paths, identity, commonName, validDays, caRotated);
207
+ dropped = await regenerate(paths, rawIdentity, commonName, validDays, caRotated, reason);
203
208
  } catch (err) {
204
209
  const message = err instanceof Error ? err.message : String(err);
205
210
  if (!canKeepServing(paths)) throw err;
206
- return describe(paths, false, null, false, null, message);
211
+ return describe(paths, false, null, false, null, message, onDisk.dropped);
207
212
  }
208
- return describe(paths, true, reason, caRotated, previousFingerprint, null);
213
+ return describe(paths, true, reason, caRotated, previousFingerprint, null, dropped);
209
214
  }
210
215
  /**
211
216
  * Operator "Regenerate": issue a new leaf under the existing local CA.
@@ -229,8 +234,39 @@ async function reissueTlsLeaf(dataDir, options) {
229
234
  });
230
235
  const previousFingerprint = readFingerprint(paths.certPath);
231
236
  (0, node_fs.mkdirSync)(tlsDir, { recursive: true });
232
- await regenerate(paths, identity, commonName, validDays, false);
233
- return describe(paths, true, "san-coverage-gap", false, previousFingerprint, null);
237
+ return describe(paths, true, "san-coverage-gap", false, previousFingerprint, null, await regenerate(paths, identity, commonName, validDays, false, null));
238
+ }
239
+ /**
240
+ * Uploaded mode keeps a generated CA beside the upload (agents' leaves are still signed
241
+ * by it). When that CA is unconstrained, replace it alone (D873): the served pair is the
242
+ * operator's and is never touched. Returns whether the CA was replaced.
243
+ */
244
+ async function replaceUnconstrainedLocalCa(dataDir, options) {
245
+ const tlsDir = (0, node_path.join)(dataDir, "tls");
246
+ const paths = {
247
+ certPath: (0, node_path.join)(tlsDir, "camstack.crt"),
248
+ keyPath: (0, node_path.join)(tlsDir, "camstack.key"),
249
+ caCertPath: (0, node_path.join)(tlsDir, "camstack-ca.crt"),
250
+ caKeyPath: (0, node_path.join)(tlsDir, "camstack-ca.key")
251
+ };
252
+ if (!(0, node_fs.existsSync)(paths.caCertPath) || !(0, node_fs.existsSync)(paths.caKeyPath)) return false;
253
+ try {
254
+ if (require_agent_https.isConstrainedCa(new require_x509_es.X509Certificate((0, node_fs.readFileSync)(paths.caCertPath, "utf-8")))) return false;
255
+ } catch {
256
+ return false;
257
+ }
258
+ const identity = require_agent_https.collectCertIdentity({
259
+ commonName: options?.commonName ?? DEFAULT_COMMON_NAME,
260
+ extraSans: options?.extraSans ?? [],
261
+ ...options?.hostFacts === void 0 ? {} : { hostFacts: options.hostFacts }
262
+ });
263
+ const ca = await require_agent_https.generateCa(/* @__PURE__ */ new Date(), identity.dnsNames);
264
+ archivePreviousCa(paths, ca);
265
+ await (0, node_fs_promises.writeFile)(`${paths.caCertPath}.new`, ca.certPem, "utf-8");
266
+ (0, _camstack_types_node.writeOwnerOnlyFileSync)(`${paths.caKeyPath}.new`, ca.keyPem);
267
+ (0, node_fs.renameSync)(`${paths.caCertPath}.new`, paths.caCertPath);
268
+ (0, node_fs.renameSync)(`${paths.caKeyPath}.new`, paths.caKeyPath);
269
+ return true;
234
270
  }
235
271
  /** True when the material already on disk can still terminate TLS. */
236
272
  function canKeepServing(paths) {
@@ -267,12 +303,18 @@ function decideRegeneration(paths, identity) {
267
303
  * moves leaves a key/certificate mismatch, which the evaluator reports as
268
304
  * `key-mismatch` and heals on the next boot.
269
305
  */
270
- async function regenerate(paths, identity, commonName, validDays, newCa) {
306
+ async function regenerate(paths, rawIdentity, commonName, validDays, newCa, reason) {
271
307
  const now = /* @__PURE__ */ new Date();
272
- const ca = newCa ? await require_agent_https.generateCa(now) : await require_agent_https.loadCa((0, node_fs.readFileSync)(paths.caCertPath, "utf-8"), (0, node_fs.readFileSync)(paths.caKeyPath, "utf-8"));
308
+ const ca = newCa ? await require_agent_https.generateCa(now, rawIdentity.dnsNames) : await require_agent_https.loadCa((0, node_fs.readFileSync)(paths.caCertPath, "utf-8"), (0, node_fs.readFileSync)(paths.caKeyPath, "utf-8"));
309
+ const constraints = require_agent_https.readNameConstraints(ca.cert);
310
+ const { identity, dropped } = constraints === null ? {
311
+ identity: rawIdentity,
312
+ dropped: []
313
+ } : require_agent_https.permitsIdentity(constraints, rawIdentity);
273
314
  const leaf = await require_agent_https.issueLeaf(ca, identity, commonName, validDays, now);
274
315
  const chainPem = `${leaf.certPem.trimEnd()}\n${ca.certPem.trimEnd()}\n`;
275
316
  if (newCa) {
317
+ if (reason === "ca-unconstrained") archivePreviousCa(paths, ca);
276
318
  await (0, node_fs_promises.writeFile)(`${paths.caCertPath}.new`, ca.certPem, "utf-8");
277
319
  (0, _camstack_types_node.writeOwnerOnlyFileSync)(`${paths.caKeyPath}.new`, ca.keyPem);
278
320
  (0, node_fs.renameSync)(`${paths.caCertPath}.new`, paths.caCertPath);
@@ -282,8 +324,56 @@ async function regenerate(paths, identity, commonName, validDays, newCa) {
282
324
  await (0, node_fs_promises.writeFile)(`${paths.certPath}.new`, chainPem, "utf-8");
283
325
  (0, node_fs.renameSync)(`${paths.keyPath}.new`, paths.keyPath);
284
326
  (0, node_fs.renameSync)(`${paths.certPath}.new`, paths.certPath);
327
+ return dropped;
328
+ }
329
+ /** The identity as far as the CA on disk may certify it; unchanged while that CA is absent or unconstrained. */
330
+ function constrainToDiskCa(paths, identity) {
331
+ try {
332
+ const constraints = require_agent_https.readNameConstraints(new require_x509_es.X509Certificate((0, node_fs.readFileSync)(paths.caCertPath, "utf-8")));
333
+ return constraints === null ? {
334
+ identity,
335
+ dropped: []
336
+ } : require_agent_https.permitsIdentity(constraints, identity);
337
+ } catch {
338
+ return {
339
+ identity,
340
+ dropped: []
341
+ };
342
+ }
343
+ }
344
+ /**
345
+ * One-time replacement of an UNCONSTRAINED generated CA (D873): keep the old material
346
+ * beside the new one until the new leaf is serving (`retirePreviousLocalCa`), and write the
347
+ * marker the admin UI reads to tell the operator that clients must re-trust the CA.
348
+ */
349
+ function archivePreviousCa(paths, replacement) {
350
+ if (!(0, node_fs.existsSync)(paths.caCertPath)) return;
351
+ const previousCert = `${paths.caCertPath}.previous`;
352
+ const previousKey = `${paths.caKeyPath}.previous`;
353
+ (0, node_fs.copyFileSync)(paths.caCertPath, previousCert);
354
+ if ((0, node_fs.existsSync)(paths.caKeyPath)) (0, node_fs.copyFileSync)(paths.caKeyPath, previousKey);
355
+ const previousFingerprint = readFingerprint(paths.caCertPath);
356
+ (0, _camstack_types_node.writeOwnerOnlyFileSync)((0, node_path.join)((0, node_path.dirname)(paths.caCertPath), "ca-replaced.json"), `${JSON.stringify({
357
+ replacedAt: (/* @__PURE__ */ new Date()).toISOString(),
358
+ previousFingerprintSha256: previousFingerprint,
359
+ fingerprintSha256: new node_crypto.X509Certificate(replacement.certPem).fingerprint256
360
+ })}\n`);
361
+ }
362
+ /** Marker path for the status reader and the retire step. */
363
+ function caReplacementMarkerFile(dataDir) {
364
+ return (0, node_path.join)(dataDir, "tls", "ca-replaced.json");
365
+ }
366
+ /**
367
+ * Called once the HTTPS listener serves the leaf of the replacement CA: drop the retired
368
+ * unconstrained CA, key first. Idempotent; returns whether anything was removed.
369
+ */
370
+ function retirePreviousLocalCa(dataDir) {
371
+ const base = (0, node_path.join)(dataDir, "tls", "camstack-ca");
372
+ const present = [`${base}.key.previous`, `${base}.crt.previous`].filter((file) => (0, node_fs.existsSync)(file));
373
+ for (const file of present) (0, node_fs.rmSync)(file, { force: true });
374
+ return present.length > 0;
285
375
  }
286
- function describe(paths, generated, reason, caRotated, previousFingerprintSha256, reissueError) {
376
+ function describe(paths, generated, reason, caRotated, previousFingerprintSha256, reissueError, droppedNames) {
287
377
  const leaf = new node_crypto.X509Certificate((0, node_fs.readFileSync)(paths.certPath));
288
378
  const ca = new node_crypto.X509Certificate((0, node_fs.readFileSync)(paths.caCertPath));
289
379
  const san = leaf.subjectAltName ?? "";
@@ -297,6 +387,7 @@ function describe(paths, generated, reason, caRotated, previousFingerprintSha256
297
387
  caFingerprintSha256: ca.fingerprint256,
298
388
  validTo: new Date(leaf.validTo).toISOString(),
299
389
  sans: san === "" ? [] : san.split(", "),
390
+ droppedNames,
300
391
  reissueError
301
392
  };
302
393
  }
@@ -312,6 +403,140 @@ function loadTlsCert(certPath, keyPath) {
312
403
  };
313
404
  }
314
405
  //#endregion
406
+ //#region src/tls/uploaded-cert.ts
407
+ var MESSAGES = {
408
+ "tls.cert-unparsable": "Certificate PEM could not be parsed.",
409
+ "tls.key-unparsable": "Private key PEM could not be parsed.",
410
+ "tls.key-mismatch": "Private key does not match the certificate.",
411
+ "tls.missing-server-auth": "Certificate is missing extendedKeyUsage = serverAuth.",
412
+ "tls.ca-required": "This certificate was not issued by a public certificate authority, so its CA certificate is required: devices need it to trust the hub. Paste the CA certificate (for mkcert, the rootCA.pem in the folder `mkcert -CAROOT` prints).",
413
+ "tls.ca-unparsable": "The CA certificate could not be parsed.",
414
+ "tls.ca-not-issuer": "The certificate was not issued by the CA certificate you supplied.",
415
+ "tls.ca-not-a-ca": "The supplied CA certificate is not a certificate authority (CA:TRUE is missing).",
416
+ "tls.ca-expired": "The supplied CA certificate has expired or is not valid yet."
417
+ };
418
+ function failure(code) {
419
+ return {
420
+ ok: false,
421
+ code,
422
+ error: MESSAGES[code]
423
+ };
424
+ }
425
+ /**
426
+ * Operator-supplied material must parse, match its key, and carry
427
+ * `extendedKeyUsage = serverAuth` — the same leaf defects D227 refused
428
+ * to keep generating.
429
+ */
430
+ function validateUploadedTls(certPem, keyPem) {
431
+ let leaf;
432
+ try {
433
+ leaf = new node_crypto.X509Certificate(certPem);
434
+ } catch {
435
+ return failure("tls.cert-unparsable");
436
+ }
437
+ try {
438
+ if (!leaf.checkPrivateKey((0, node_crypto.createPrivateKey)(keyPem))) return failure("tls.key-mismatch");
439
+ } catch {
440
+ return failure("tls.key-unparsable");
441
+ }
442
+ const eku = leaf.keyUsage;
443
+ if (eku === void 0 || !eku.includes("1.3.6.1.5.5.7.3.1")) return failure("tls.missing-server-auth");
444
+ return {
445
+ ok: true,
446
+ fingerprintSha256: leaf.fingerprint256,
447
+ validTo: new Date(leaf.validTo).toISOString()
448
+ };
449
+ }
450
+ /** Every certificate in a PEM bundle, in order. */
451
+ function pemCertificates(pem) {
452
+ const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g);
453
+ return blocks === null ? [] : blocks.map((block) => `${block}\n`);
454
+ }
455
+ function parseAll(pems) {
456
+ const parsed = [];
457
+ for (const pem of pems) try {
458
+ parsed.push(new node_crypto.X509Certificate(pem));
459
+ } catch {}
460
+ return parsed;
461
+ }
462
+ function issuedBy(child, issuer) {
463
+ try {
464
+ return child.checkIssued(issuer) && child.verify(issuer.publicKey);
465
+ } catch {
466
+ return false;
467
+ }
468
+ }
469
+ var defaultPublicRoots;
470
+ /** The trust anchors of public CAs that ship with Node. */
471
+ function publicRootCertificates() {
472
+ defaultPublicRoots ??= parseAll(node_tls.rootCertificates);
473
+ return defaultPublicRoots;
474
+ }
475
+ /**
476
+ * True when the served chain leads to a public CA that clients already trust:
477
+ * the leaf, or one of the chain's own certificates, is signed by a public root.
478
+ * Such a certificate needs nothing installed on a device, so no CA is required.
479
+ */
480
+ function chainsToPublicRoot(chainPem, roots = publicRootCertificates()) {
481
+ const [leaf, ...rest] = parseAll(pemCertificates(chainPem));
482
+ if (leaf === void 0) return false;
483
+ let current = leaf;
484
+ const pool = [...rest];
485
+ for (let depth = 0; depth < 8; depth += 1) {
486
+ if (roots.some((root) => issuedBy(current, root))) return true;
487
+ const next = pool.find((candidate) => candidate !== current && issuedBy(current, candidate));
488
+ if (next === void 0) return false;
489
+ pool.splice(pool.indexOf(next), 1);
490
+ current = next;
491
+ }
492
+ return false;
493
+ }
494
+ /** Is `cert` a usable CA at `now`? */
495
+ function caProblem(cert, now) {
496
+ if (!cert.ca) return "tls.ca-not-a-ca";
497
+ if (now < new Date(cert.validFrom) || now > new Date(cert.validTo)) return "tls.ca-expired";
498
+ return null;
499
+ }
500
+ /**
501
+ * The CA rule for an upload (D873). A CA the operator supplies must parse, be a CA, be in
502
+ * date and actually have signed the leaf. Without one, the upload is accepted only when
503
+ * its own chain carries the issuer or leads to a public root; anything else is refused,
504
+ * because a device cannot be told to trust a certificate nobody can hand it the CA of.
505
+ */
506
+ function checkUploadedCa(chainPem, caPem, options) {
507
+ const now = options?.now ?? /* @__PURE__ */ new Date();
508
+ const [leaf, ...chainRest] = parseAll(pemCertificates(chainPem));
509
+ if (leaf === void 0) return failure("tls.cert-unparsable");
510
+ if (caPem !== void 0 && caPem.trim() !== "") {
511
+ const candidates = parseAll(pemCertificates(caPem));
512
+ if (candidates.length === 0) return failure("tls.ca-unparsable");
513
+ return pickIssuer(leaf, candidates, now, chainPem, options?.publicRoots);
514
+ }
515
+ const fromChain = chainRest.filter((cert) => issuedBy(leaf, cert));
516
+ if (fromChain.length > 0) return pickIssuer(leaf, fromChain, now, chainPem, options?.publicRoots);
517
+ if (chainsToPublicRoot(chainPem, options?.publicRoots)) return {
518
+ ok: true,
519
+ issuerPem: null,
520
+ publicChain: true
521
+ };
522
+ return failure("tls.ca-required");
523
+ }
524
+ function pickIssuer(leaf, candidates, now, chainPem, publicRoots) {
525
+ const signers = candidates.filter((candidate) => issuedBy(leaf, candidate));
526
+ if (signers.length === 0) return failure("tls.ca-not-issuer");
527
+ let firstProblem = null;
528
+ for (const signer of signers) {
529
+ const problem = caProblem(signer, now);
530
+ if (problem === null) return {
531
+ ok: true,
532
+ issuerPem: `${signer.toString().trimEnd()}\n`,
533
+ publicChain: chainsToPublicRoot(chainPem, publicRoots)
534
+ };
535
+ firstProblem ??= problem;
536
+ }
537
+ return failure(firstProblem ?? "tls.ca-not-issuer");
538
+ }
539
+ //#endregion
315
540
  //#region src/tls/tls-status.ts
316
541
  function tlsDir(dataDir) {
317
542
  return (0, node_path.join)(dataDir, "tls");
@@ -344,6 +569,44 @@ function writeExtraSans(dataDir, sans) {
344
569
  (0, node_fs.mkdirSync)(tlsDir(dataDir), { recursive: true });
345
570
  (0, node_fs.writeFileSync)((0, node_path.join)(tlsDir(dataDir), "extra-sans.json"), `${JSON.stringify(sans)}\n`, "utf-8");
346
571
  }
572
+ /** The kept copy of the last upload: the chain and key as the operator supplied them. */
573
+ var STORED_UPLOAD_CERT_FILE = "uploaded.crt";
574
+ var STORED_UPLOAD_KEY_FILE = "uploaded.key";
575
+ var CA_REPLACED_MARKER_FILE = "ca-replaced.json";
576
+ function readStoredUpload(dir) {
577
+ const certPath = (0, node_path.join)(dir, STORED_UPLOAD_CERT_FILE);
578
+ if (!(0, node_fs.existsSync)(certPath) || !(0, node_fs.existsSync)((0, node_path.join)(dir, "uploaded.key"))) return null;
579
+ try {
580
+ const leaf = new node_crypto.X509Certificate((0, node_fs.readFileSync)(certPath, "utf-8"));
581
+ return {
582
+ leafFingerprintSha256: leaf.fingerprint256,
583
+ validTo: new Date(leaf.validTo).toISOString(),
584
+ hasCa: (0, node_fs.existsSync)((0, node_path.join)(dir, UPLOADED_CA_FILE))
585
+ };
586
+ } catch {
587
+ return null;
588
+ }
589
+ }
590
+ function readLocalCaConstrained(dir) {
591
+ const caPath = (0, node_path.join)(dir, "camstack-ca.crt");
592
+ if (!(0, node_fs.existsSync)(caPath)) return null;
593
+ try {
594
+ return require_agent_https.isConstrainedCa(new require_x509_es.X509Certificate((0, node_fs.readFileSync)(caPath, "utf-8")));
595
+ } catch {
596
+ return null;
597
+ }
598
+ }
599
+ function readCaReplacedAt(dir) {
600
+ const path = (0, node_path.join)(dir, CA_REPLACED_MARKER_FILE);
601
+ if (!(0, node_fs.existsSync)(path)) return null;
602
+ try {
603
+ const parsed = JSON.parse((0, node_fs.readFileSync)(path, "utf-8"));
604
+ const at = parsed !== null && typeof parsed === "object" ? Reflect.get(parsed, "replacedAt") : null;
605
+ return typeof at === "string" ? at : null;
606
+ } catch {
607
+ return null;
608
+ }
609
+ }
347
610
  /** Where an upload's own CA is kept — never over the local CA's `camstack-ca.crt`. */
348
611
  var UPLOADED_CA_FILE = "uploaded-ca.crt";
349
612
  /** Every certificate in a PEM bundle, in order. */
@@ -398,7 +661,11 @@ function readTlsAccessStatus(dataDir, servedLeafFingerprint) {
398
661
  sans: [],
399
662
  caCertPem: (mode === "uploaded" ? null : generatedIssuer(dir))?.pem ?? null,
400
663
  reissueError: null,
401
- restartRequired: false
664
+ restartRequired: false,
665
+ caMissing: false,
666
+ localCaConstrained: readLocalCaConstrained(dir),
667
+ caReplacedAt: readCaReplacedAt(dir),
668
+ storedUpload: readStoredUpload(dir)
402
669
  };
403
670
  try {
404
671
  const chainPem = (0, node_fs.readFileSync)(certPath, "utf-8");
@@ -413,7 +680,11 @@ function readTlsAccessStatus(dataDir, servedLeafFingerprint) {
413
680
  sans: san === "" ? [] : san.split(", "),
414
681
  caCertPem: ca?.pem ?? null,
415
682
  reissueError: null,
416
- restartRequired: servedLeafFingerprint !== null && servedLeafFingerprint !== leaf.fingerprint256
683
+ restartRequired: servedLeafFingerprint !== null && servedLeafFingerprint !== leaf.fingerprint256,
684
+ caMissing: mode === "uploaded" && ca === null && !chainsToPublicRoot(chainPem),
685
+ localCaConstrained: readLocalCaConstrained(dir),
686
+ caReplacedAt: readCaReplacedAt(dir),
687
+ storedUpload: readStoredUpload(dir)
417
688
  };
418
689
  } catch {
419
690
  return {
@@ -424,7 +695,11 @@ function readTlsAccessStatus(dataDir, servedLeafFingerprint) {
424
695
  sans: [],
425
696
  caCertPem: null,
426
697
  reissueError: "Certificate on disk could not be parsed.",
427
- restartRequired: false
698
+ restartRequired: false,
699
+ caMissing: false,
700
+ localCaConstrained: readLocalCaConstrained(dir),
701
+ caReplacedAt: readCaReplacedAt(dir),
702
+ storedUpload: readStoredUpload(dir)
428
703
  };
429
704
  }
430
705
  }
@@ -446,7 +721,8 @@ async function prepareBootTls(dataDir, options) {
446
721
  mode: "uploaded",
447
722
  pair: loadTlsCert(certPath, keyPath),
448
723
  ensured: void 0,
449
- uploadedMissing: false
724
+ uploadedMissing: false,
725
+ dormantCaReplaced: await replaceUnconstrainedLocalCa(dataDir, options)
450
726
  };
451
727
  const uploadedMissing = marked === "uploaded";
452
728
  if (uploadedMissing) writeTlsMode(dataDir, "generated");
@@ -455,7 +731,8 @@ async function prepareBootTls(dataDir, options) {
455
731
  mode: "generated",
456
732
  pair: loadTlsCert(ensured.certPath, ensured.keyPath),
457
733
  ensured,
458
- uploadedMissing
734
+ uploadedMissing,
735
+ dormantCaReplaced: false
459
736
  };
460
737
  }
461
738
  //#endregion
@@ -533,66 +810,51 @@ function reloadHttpsListener(pair) {
533
810
  }
534
811
  }
535
812
  //#endregion
536
- //#region src/tls/uploaded-cert.ts
537
- /**
538
- * Operator-supplied material must parse, match its key, and carry
539
- * `extendedKeyUsage = serverAuth` — the same leaf defects D227 refused
540
- * to keep generating.
541
- */
542
- function validateUploadedTls(certPem, keyPem) {
543
- let leaf;
544
- try {
545
- leaf = new node_crypto.X509Certificate(certPem);
546
- } catch {
547
- return {
548
- ok: false,
549
- error: "Certificate PEM could not be parsed."
550
- };
551
- }
552
- try {
553
- if (!leaf.checkPrivateKey((0, node_crypto.createPrivateKey)(keyPem))) return {
554
- ok: false,
555
- error: "Private key does not match the certificate."
556
- };
557
- } catch {
558
- return {
559
- ok: false,
560
- error: "Private key PEM could not be parsed."
561
- };
562
- }
563
- const eku = leaf.keyUsage;
564
- if (eku === void 0 || !eku.includes("1.3.6.1.5.5.7.3.1")) return {
565
- ok: false,
566
- error: "Certificate is missing extendedKeyUsage = serverAuth."
567
- };
568
- return {
569
- ok: true,
570
- fingerprintSha256: leaf.fingerprint256,
571
- validTo: new Date(leaf.validTo).toISOString()
572
- };
573
- }
574
- //#endregion
575
813
  //#region src/tls/tls-actions.ts
576
814
  function withNewline(pem) {
577
815
  return pem.endsWith("\n") ? pem : `${pem}\n`;
578
816
  }
579
817
  /**
580
- * Store an operator's certificate. The leaf (or chain) and key replace the
581
- * served pair; a CA, when given, goes to its own file. The local CA
582
- * (`camstack-ca.crt` + key) is never touched — it did not sign the upload,
583
- * and it is what "Revert to generated" and every device that trusts it
584
- * still need.
818
+ * Store an operator's certificate (D873). The material is validated first, and a
819
+ * certificate that does not lead to a public CA must come with the CA that signed it
820
+ * (validated: it is a CA, in date, and issued the leaf) - otherwise "Download CA" would
821
+ * have nothing to hand out. Nothing is written when anything is refused.
822
+ *
823
+ * The leaf (or chain) and key are written twice: as the served pair, and as a kept copy
824
+ * (`uploaded.crt` / `uploaded.key`, with the issuing CA in `uploaded-ca.crt`) that
825
+ * "Revert to generated" leaves in place so the upload can be selected again. The local
826
+ * CA (`camstack-ca.crt` + key) is never touched - it did not sign the upload, and it is
827
+ * what every device that trusts it still needs.
585
828
  */
586
- async function storeUploadedCertificate(dataDir, input) {
829
+ async function storeUploadedCertificate(dataDir, input, options) {
587
830
  const valid = validateUploadedTls(input.certPem, input.keyPem);
588
- if (!valid.ok) throw new Error(valid.error);
831
+ if (!valid.ok) throw require_dist.userError(valid.code, {}, valid.error);
832
+ const ca = checkUploadedCa(input.certPem, input.caPem, options);
833
+ if (!ca.ok) throw require_dist.userError(ca.code, {}, ca.error);
589
834
  const dir = (0, node_path.join)(dataDir, "tls");
590
835
  await (0, node_fs_promises.mkdir)(dir, { recursive: true });
591
- await (0, node_fs_promises.writeFile)((0, node_path.join)(dir, "camstack.crt"), withNewline(input.certPem));
592
- (0, _camstack_types_node.writeOwnerOnlyFileSync)((0, node_path.join)(dir, "camstack.key"), withNewline(input.keyPem));
836
+ const certText = withNewline(input.certPem);
837
+ const keyText = withNewline(input.keyPem);
838
+ (0, _camstack_types_node.writeOwnerOnlyFileSync)((0, node_path.join)(dir, STORED_UPLOAD_KEY_FILE), keyText);
839
+ await (0, node_fs_promises.writeFile)((0, node_path.join)(dir, STORED_UPLOAD_CERT_FILE), certText);
593
840
  const caPath = (0, node_path.join)(dir, UPLOADED_CA_FILE);
594
- if (input.caPem !== void 0 && input.caPem.trim() !== "") await (0, node_fs_promises.writeFile)(caPath, withNewline(input.caPem));
841
+ if (ca.issuerPem !== null) await (0, node_fs_promises.writeFile)(caPath, ca.issuerPem);
595
842
  else (0, node_fs.rmSync)(caPath, { force: true });
843
+ await (0, node_fs_promises.writeFile)((0, node_path.join)(dir, "camstack.crt"), certText);
844
+ (0, _camstack_types_node.writeOwnerOnlyFileSync)((0, node_path.join)(dir, "camstack.key"), keyText);
845
+ writeTlsMode(dataDir, "uploaded");
846
+ }
847
+ /**
848
+ * Serve the kept upload again after a revert (D873). Refused with a clear error when none
849
+ * is kept; the generated material stays on disk and is untouched.
850
+ */
851
+ async function useStoredUploadedCertificate(dataDir) {
852
+ const dir = (0, node_path.join)(dataDir, "tls");
853
+ const certPath = (0, node_path.join)(dir, STORED_UPLOAD_CERT_FILE);
854
+ const keyPath = (0, node_path.join)(dir, STORED_UPLOAD_KEY_FILE);
855
+ if (!(0, node_fs.existsSync)(certPath) || !(0, node_fs.existsSync)(keyPath)) throw require_dist.userError("tls.no-stored-upload", {}, "No uploaded certificate is kept on this node.");
856
+ (0, node_fs.copyFileSync)(certPath, (0, node_path.join)(dir, "camstack.crt"));
857
+ (0, node_fs.copyFileSync)(keyPath, (0, node_path.join)(dir, "camstack.key"));
596
858
  writeTlsMode(dataDir, "uploaded");
597
859
  }
598
860
  /** Issue a new generated leaf under the local CA. Refused while an upload is served. */
@@ -600,10 +862,20 @@ async function regenerateCertificate(dataDir, options) {
600
862
  if (readTlsMode(dataDir) === "uploaded") throw new Error("Cannot regenerate an uploaded certificate — revert to generated first.");
601
863
  await reissueTlsLeaf(dataDir, options);
602
864
  }
603
- /** Drop the upload and serve the generated local-CA leaf again (D227). */
865
+ /**
866
+ * Serve the generated local-CA leaf again (D227). The uploaded certificate and its CA stay
867
+ * on disk (D873) so it can be selected again; an upload made before they were kept is
868
+ * copied aside first, from the pair being served.
869
+ */
604
870
  async function revertToGeneratedCertificate(dataDir, options) {
871
+ const dir = (0, node_path.join)(dataDir, "tls");
872
+ const keptCert = (0, node_path.join)(dir, STORED_UPLOAD_CERT_FILE);
873
+ const keptKey = (0, node_path.join)(dir, STORED_UPLOAD_KEY_FILE);
874
+ if (readTlsMode(dataDir) === "uploaded" && !(0, node_fs.existsSync)(keptCert) && (0, node_fs.existsSync)((0, node_path.join)(dir, "camstack.crt")) && (0, node_fs.existsSync)((0, node_path.join)(dir, "camstack.key"))) {
875
+ (0, node_fs.copyFileSync)((0, node_path.join)(dir, "camstack.crt"), keptCert);
876
+ (0, node_fs.copyFileSync)((0, node_path.join)(dir, "camstack.key"), keptKey);
877
+ }
605
878
  writeTlsMode(dataDir, "generated");
606
- (0, node_fs.rmSync)((0, node_path.join)(dataDir, "tls", UPLOADED_CA_FILE), { force: true });
607
879
  await ensureTlsCert(dataDir, options);
608
880
  }
609
881
  /**
@@ -623,7 +895,7 @@ function applyCertificateToListener(dataDir) {
623
895
  //#region src/builtins/local-network/hub-mdns-ciao.ts
624
896
  function createCiaoPublisher() {
625
897
  return { async publish(announcement) {
626
- const { getResponder } = await Promise.resolve().then(() => /* @__PURE__ */ require_chunk.__toESM(require("./lib-NNxHSOIE.js").default));
898
+ const { getResponder } = await Promise.resolve().then(() => /* @__PURE__ */ require_chunk.__toESM(require("./lib-8oZrELLm.js").default));
627
899
  const responder = getResponder();
628
900
  const service = responder.createService({
629
901
  name: announcement.name,
@@ -681,6 +953,24 @@ Object.defineProperty(exports, "bindPendingLanHttp", {
681
953
  return bindPendingLanHttp;
682
954
  }
683
955
  });
956
+ Object.defineProperty(exports, "caReplacementMarkerFile", {
957
+ enumerable: true,
958
+ get: function() {
959
+ return caReplacementMarkerFile;
960
+ }
961
+ });
962
+ Object.defineProperty(exports, "chainsToPublicRoot", {
963
+ enumerable: true,
964
+ get: function() {
965
+ return chainsToPublicRoot;
966
+ }
967
+ });
968
+ Object.defineProperty(exports, "checkUploadedCa", {
969
+ enumerable: true,
970
+ get: function() {
971
+ return checkUploadedCa;
972
+ }
973
+ });
684
974
  Object.defineProperty(exports, "clearHttpsListener", {
685
975
  enumerable: true,
686
976
  get: function() {
@@ -777,6 +1067,18 @@ Object.defineProperty(exports, "reloadHttpsListener", {
777
1067
  return reloadHttpsListener;
778
1068
  }
779
1069
  });
1070
+ Object.defineProperty(exports, "replaceUnconstrainedLocalCa", {
1071
+ enumerable: true,
1072
+ get: function() {
1073
+ return replaceUnconstrainedLocalCa;
1074
+ }
1075
+ });
1076
+ Object.defineProperty(exports, "retirePreviousLocalCa", {
1077
+ enumerable: true,
1078
+ get: function() {
1079
+ return retirePreviousLocalCa;
1080
+ }
1081
+ });
780
1082
  Object.defineProperty(exports, "revertToGeneratedCertificate", {
781
1083
  enumerable: true,
782
1084
  get: function() {
@@ -801,6 +1103,12 @@ Object.defineProperty(exports, "storeUploadedCertificate", {
801
1103
  return storeUploadedCertificate;
802
1104
  }
803
1105
  });
1106
+ Object.defineProperty(exports, "useStoredUploadedCertificate", {
1107
+ enumerable: true,
1108
+ get: function() {
1109
+ return useStoredUploadedCertificate;
1110
+ }
1111
+ });
804
1112
  Object.defineProperty(exports, "validateUploadedTls", {
805
1113
  enumerable: true,
806
1114
  get: function() {