@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,5 +1,5 @@
1
1
  const require_chunk = require("./chunk-Cek0wNdY.js");
2
- const require_x509_es = require("./x509.es-r7Ht1thA.js");
2
+ const require_x509_es = require("./x509.es-C4cOo48M.js");
3
3
  let node_crypto = require("node:crypto");
4
4
  let _camstack_types_node = require("@camstack/types/node");
5
5
  let node_net = require("node:net");
@@ -101,6 +101,188 @@ function getProcessAgentHttpsNames() {
101
101
  return value !== null && typeof value === "object" && typeof Reflect.get(value, "takenByOthers") === "function" ? value : null;
102
102
  }
103
103
  //#endregion
104
+ //#region src/tls/cert-name-constraints.ts
105
+ /**
106
+ * Name constraints of the hub's generated local CA (D873).
107
+ *
108
+ * An unconstrained private CA vouches for EVERY site on the internet to any
109
+ * device that trusts it, so a leaked CA key would let its holder impersonate
110
+ * a bank. The generated CA therefore carries a CRITICAL `nameConstraints`
111
+ * extension (RFC 5280 4.2.1.10) that limits what it can ever certify to the
112
+ * private address space and local-only names. A verifier that does not
113
+ * understand a critical extension must reject the certificate, so the
114
+ * constraint cannot be silently ignored by a conforming client.
115
+ *
116
+ * Pure: no filesystem, no clock. `cert-issue.ts` embeds the extension,
117
+ * `cert-manager.ts` filters leaf names through {@link permitsIdentity}.
118
+ */
119
+ /** `id-ce-nameConstraints`. */
120
+ var NAME_CONSTRAINTS_OID = "2.5.29.30";
121
+ /** The address space the generated CA may certify. 100.64.0.0/10 is CGNAT, which Tailscale hands out. Loopback is for the hub's own `127.0.0.1` / `::1` SANs. */
122
+ var PERMITTED_IP_RANGES = [
123
+ "10.0.0.0/8",
124
+ "172.16.0.0/12",
125
+ "192.168.0.0/16",
126
+ "100.64.0.0/10",
127
+ "127.0.0.0/8",
128
+ "fc00::/7",
129
+ "::1/128"
130
+ ];
131
+ /**
132
+ * Local-only DNS suffixes: none of them is delegated in public DNS. A leading
133
+ * dot permits every name under the suffix and not the suffix itself.
134
+ */
135
+ var PERMITTED_DNS_SUFFIXES = [
136
+ ".local",
137
+ ".lan",
138
+ ".home.arpa",
139
+ ".internal",
140
+ ".localdomain",
141
+ "localhost"
142
+ ];
143
+ var LABEL_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/i;
144
+ /** A name that cannot collide with public DNS: a single label, or under a local-only suffix. */
145
+ function isLocalOnlyHostname(name) {
146
+ const lower = name.toLowerCase();
147
+ if (lower.length === 0 || lower.length > 253) return false;
148
+ if (!lower.includes(".")) return LABEL_RE.test(lower);
149
+ if (!PERMITTED_DNS_SUFFIXES.some((suffix) => suffix.startsWith(".") && lower.endsWith(suffix))) return false;
150
+ return lower.split(".").every((label) => LABEL_RE.test(label));
151
+ }
152
+ /**
153
+ * The constraint set for a new CA. `hostnames` are the hub's own local-only
154
+ * names at creation time (a single-label OS hostname has no suffix to hide
155
+ * under), added as exact entries; anything that could collide with public DNS
156
+ * is dropped here, never constrained in.
157
+ */
158
+ function buildNameConstraintSet(hostnames) {
159
+ const dns = new Set(PERMITTED_DNS_SUFFIXES);
160
+ for (const name of hostnames) {
161
+ const lower = name.trim().toLowerCase();
162
+ if ((0, node_net.isIP)(lower) === 0 && isLocalOnlyHostname(lower)) dns.add(lower);
163
+ }
164
+ return {
165
+ ipRanges: PERMITTED_IP_RANGES,
166
+ dnsNames: [...dns],
167
+ critical: true
168
+ };
169
+ }
170
+ /** The `nameConstraints` extension for a CA: permitted subtrees only, marked critical. */
171
+ function createNameConstraintsExtension(set) {
172
+ const subtrees = [...set.ipRanges.map((range) => new require_x509_es.GeneralSubtree({ base: new require_x509_es.GeneralName({ iPAddress: range }) })), ...set.dnsNames.map((name) => new require_x509_es.GeneralSubtree({ base: new require_x509_es.GeneralName({ dNSName: name }) }))];
173
+ const value = require_x509_es.AsnConvert.serialize(new require_x509_es.NameConstraints({ permittedSubtrees: new require_x509_es.GeneralSubtrees(subtrees) }));
174
+ return new require_x509_es.Extension(NAME_CONSTRAINTS_OID, set.critical, value);
175
+ }
176
+ /** The CA's constraint set, or `null` when the certificate carries no `nameConstraints`. */
177
+ function readNameConstraints(cert) {
178
+ const ext = cert.extensions.find((e) => e.type === NAME_CONSTRAINTS_OID);
179
+ if (ext === void 0) return null;
180
+ const parsed = require_x509_es.AsnConvert.parse(ext.value, require_x509_es.NameConstraints);
181
+ const ipRanges = [];
182
+ const dnsNames = [];
183
+ for (const subtree of parsed.permittedSubtrees ?? []) {
184
+ if (subtree.base.iPAddress !== void 0) ipRanges.push(subtree.base.iPAddress);
185
+ if (subtree.base.dNSName !== void 0) dnsNames.push(subtree.base.dNSName.toLowerCase());
186
+ }
187
+ return {
188
+ ipRanges,
189
+ dnsNames,
190
+ critical: ext.critical
191
+ };
192
+ }
193
+ /**
194
+ * True when a CA is safe to keep as the hub's anchor: constrained to a
195
+ * non-empty permitted set AND marked critical. A non-critical constraint is
196
+ * ignored by clients that do not know the extension, which is the same as none.
197
+ */
198
+ function isConstrainedCa(cert) {
199
+ const set = readNameConstraints(cert);
200
+ return set !== null && set.critical && (set.ipRanges.length > 0 || set.dnsNames.length > 0);
201
+ }
202
+ function ipv4ToBigInt(address) {
203
+ return address.split(".").reduce((acc, octet) => acc << 8n | BigInt(Number(octet)), 0n);
204
+ }
205
+ function ipv6ToBigInt(address) {
206
+ const mapped = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(address);
207
+ let text = address;
208
+ if (mapped !== null) {
209
+ const v4 = ipv4ToBigInt(mapped[2] ?? "0.0.0.0");
210
+ text = `${mapped[1] ?? ""}${(v4 >> 16n & 65535n).toString(16)}:${(v4 & 65535n).toString(16)}`;
211
+ }
212
+ const halves = text.split("::");
213
+ if (halves.length > 2) return null;
214
+ const head = (halves[0] ?? "") === "" ? [] : (halves[0] ?? "").split(":");
215
+ const tail = halves.length === 2 && (halves[1] ?? "") !== "" ? (halves[1] ?? "").split(":") : [];
216
+ const fill = halves.length === 2 ? 8 - head.length - tail.length : 0;
217
+ if (fill < 0 || halves.length === 1 && head.length !== 8) return null;
218
+ return [
219
+ ...head,
220
+ ...Array(fill).fill("0"),
221
+ ...tail
222
+ ].reduce((acc, group) => acc << 16n | BigInt(Number.parseInt(group, 16)), 0n);
223
+ }
224
+ function toBigInt(address) {
225
+ const family = (0, node_net.isIP)(address);
226
+ if (family === 4) return {
227
+ value: ipv4ToBigInt(address),
228
+ bits: 32
229
+ };
230
+ if (family === 6) {
231
+ const value = ipv6ToBigInt(address);
232
+ return value === null ? null : {
233
+ value,
234
+ bits: 128
235
+ };
236
+ }
237
+ return null;
238
+ }
239
+ function ipInRange(address, range) {
240
+ const [base, prefixText] = range.split("/");
241
+ if (base === void 0 || prefixText === void 0) return false;
242
+ const target = toBigInt(address);
243
+ const net = toBigInt(base);
244
+ const prefix = Number(prefixText);
245
+ if (target === null || net === null || target.bits !== net.bits) return false;
246
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > net.bits) return false;
247
+ const shift = BigInt(net.bits - prefix);
248
+ return target.value >> shift === net.value >> shift;
249
+ }
250
+ /** RFC 5280 DNS matching: a leading dot is a strict subtree, a bare name is itself and everything under it. */
251
+ function dnsPermitted(name, constraint) {
252
+ const lower = name.toLowerCase();
253
+ if (constraint.startsWith(".")) return lower.endsWith(constraint);
254
+ return lower === constraint || lower.endsWith(`.${constraint}`);
255
+ }
256
+ /** Is this SAN value (an IP literal or a DNS name) inside the permitted set? */
257
+ function permitsName(set, value) {
258
+ if ((0, node_net.isIP)(value) !== 0) return set.ipRanges.some((range) => ipInRange(value, range));
259
+ return set.dnsNames.some((constraint) => dnsPermitted(value, constraint));
260
+ }
261
+ /**
262
+ * The part of an identity the CA is allowed to certify. A leaf naming
263
+ * anything outside the constraints makes the WHOLE chain fail validation in
264
+ * OpenSSL, so such names are dropped (and reported), never signed. The
265
+ * required lists shrink the same way: otherwise a name the CA cannot certify
266
+ * would count as a permanent SAN gap and reissue the leaf on every boot.
267
+ */
268
+ function permitsIdentity(set, identity) {
269
+ const dropped = /* @__PURE__ */ new Set();
270
+ const keep = (values) => values.filter((value) => {
271
+ if (permitsName(set, value)) return true;
272
+ dropped.add(value);
273
+ return false;
274
+ });
275
+ return {
276
+ identity: {
277
+ dnsNames: keep(identity.dnsNames),
278
+ ipAddresses: keep(identity.ipAddresses),
279
+ requiredDnsNames: keep(identity.requiredDnsNames),
280
+ requiredIpAddresses: keep(identity.requiredIpAddresses)
281
+ },
282
+ dropped: [...dropped]
283
+ };
284
+ }
285
+ //#endregion
104
286
  //#region src/tls/cert-evaluation.ts
105
287
  /**
106
288
  * The regeneration rule (D227), as a pure function.
@@ -129,7 +311,8 @@ var REASONS_REQUIRING_NEW_CA = [
129
311
  "missing",
130
312
  "unreadable",
131
313
  "no-local-ca",
132
- "ca-expiring"
314
+ "ca-expiring",
315
+ "ca-unconstrained"
133
316
  ];
134
317
  function reasonRequiresNewCa(reason) {
135
318
  return REASONS_REQUIRING_NEW_CA.includes(reason);
@@ -141,6 +324,14 @@ function parse(pem) {
141
324
  return null;
142
325
  }
143
326
  }
327
+ /** A root without a CRITICAL name constraint (every pre-D873 install) can vouch for any site. */
328
+ function caIsUnconstrained(caPem) {
329
+ try {
330
+ return !isConstrainedCa(new require_x509_es.X509Certificate(caPem));
331
+ } catch {
332
+ return true;
333
+ }
334
+ }
144
335
  function daysBetween(from, to) {
145
336
  return (to.getTime() - from.getTime()) / MS_PER_DAY$1;
146
337
  }
@@ -171,6 +362,7 @@ function evaluateExistingCert(input) {
171
362
  const ca = parse(input.caPem);
172
363
  if (leaf === null || ca === null) return "unreadable";
173
364
  if (daysBetween(input.now, new Date(ca.validTo)) < 427) return "ca-expiring";
365
+ if (caIsUnconstrained(input.caPem)) return "ca-unconstrained";
174
366
  if (!keyMatches(leaf, input.keyPem)) return "key-mismatch";
175
367
  if (daysBetween(input.now, new Date(leaf.validTo)) < 30) return "expiring";
176
368
  if (!hasServerAuthEku(leaf)) return "missing-server-auth-eku";
@@ -383,13 +575,18 @@ function subjectAltNames(identity) {
383
575
  * A local root: 10 years, `pathlen:0` so it can sign leaves and nothing
384
576
  * else, and **no EKU** — Apple rejects an anchor asserting
385
577
  * `anyExtendedKeyUsage`, and a root with no EKU constrains nothing.
578
+ *
579
+ * It carries a CRITICAL `nameConstraints` extension (D873): private address
580
+ * ranges and local-only DNS names, plus `hostnames` (the hub's own single-label
581
+ * names at creation). A leaked key therefore cannot vouch for a public site.
386
582
  */
387
- async function generateCa(now) {
583
+ async function generateCa(now, hostnames = []) {
388
584
  const keys = await node_crypto.webcrypto.subtle.generateKey(SIGNING_ALGORITHM, true, ["sign", "verify"]);
389
585
  const extensions = [
390
586
  new require_x509_es.BasicConstraintsExtension(true, 0, true),
391
587
  new require_x509_es.KeyUsagesExtension(require_x509_es.KeyUsageFlags.keyCertSign | require_x509_es.KeyUsageFlags.cRLSign, true),
392
- await require_x509_es.SubjectKeyIdentifierExtension.create(keys.publicKey)
588
+ await require_x509_es.SubjectKeyIdentifierExtension.create(keys.publicKey),
589
+ createNameConstraintsExtension(buildNameConstraintSet(hostnames))
393
590
  ];
394
591
  const cert = await require_x509_es.X509CertificateGenerator.createSelfSigned({
395
592
  serialNumber: freshSerial(),
@@ -652,14 +849,6 @@ function describeHttpsLeaf(certPem) {
652
849
  notAfter: Date.parse(x509.validTo)
653
850
  };
654
851
  }
655
- var LOCAL_SUFFIXES = [
656
- ".local",
657
- ".lan",
658
- ".home.arpa",
659
- ".internal",
660
- ".localdomain"
661
- ];
662
- var LABEL_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/i;
663
852
  /** Private-network IPv4/IPv6: RFC 1918, CGNAT (Tailscale), link-local, ULA. */
664
853
  function isPrivateAddress(address) {
665
854
  const family = (0, node_net.isIP)(address);
@@ -677,14 +866,6 @@ function isPrivateAddress(address) {
677
866
  function isContainerBridgeAddress(address) {
678
867
  return /^172\.17\.\d{1,3}\.\d{1,3}$/.test(address);
679
868
  }
680
- /** A name that cannot collide with public DNS. */
681
- function isLocalOnlyHostname(name) {
682
- const lower = name.toLowerCase();
683
- if (lower.length === 0 || lower.length > 253) return false;
684
- if (!lower.includes(".")) return LABEL_RE.test(lower);
685
- if (!LOCAL_SUFFIXES.some((suffix) => lower.endsWith(suffix))) return false;
686
- return lower.split(".").every((label) => LABEL_RE.test(label));
687
- }
688
869
  /**
689
870
  * The names the hub will sign for `nodeId`: its node id and the address of its live mesh
690
871
  * socket - nothing the agent says about itself. `excluded` holds every name already taken
@@ -737,14 +918,21 @@ async function issueAgentHttpsLeaf(input) {
737
918
  if (!(0, node_fs.existsSync)(caCertPath) || !(0, node_fs.existsSync)(caKeyPath)) throw new Error("the hub has no local CA to sign with (no tls/camstack-ca.crt + key)");
738
919
  const excluded = new Set(input.takenByOthers ?? []);
739
920
  if ((0, node_fs.existsSync)(hubLeafPath)) for (const name of certificateNames((0, node_fs.readFileSync)(hubLeafPath, "utf8"))) excluded.add(name);
740
- const sans = acceptAgentSans({
921
+ const ca = await loadCa((0, node_fs.readFileSync)(caCertPath, "utf8"), (0, node_fs.readFileSync)(caKeyPath, "utf8"));
922
+ const policy = acceptAgentSans({
741
923
  nodeId: input.nodeId,
742
924
  observedAddress: input.observedAddress,
743
925
  excluded,
744
926
  ...input.reportedNames !== void 0 ? { reported: input.reportedNames } : {}
745
927
  });
928
+ const constraints = readNameConstraints(ca.cert);
929
+ const constrained = constraints === null ? null : permitsIdentity(constraints, policy.identity);
930
+ const sans = constrained === null ? policy : {
931
+ identity: constrained.identity,
932
+ accepted: policy.accepted.filter((name) => !constrained.dropped.includes(name)),
933
+ rejected: [...policy.rejected, ...constrained.dropped.map((name) => `${name} (outside the local CA's name constraints)`)]
934
+ };
746
935
  if (sans.accepted.length === 0) throw new Error(`no name of node '${input.nodeId}' can be certified (${sans.rejected.join("; ") || "nothing offered"})`);
747
- const ca = await loadCa((0, node_fs.readFileSync)(caCertPath, "utf8"), (0, node_fs.readFileSync)(caKeyPath, "utf8"));
748
936
  return {
749
937
  certPem: await issueLeafFromCsr(ca, input.csrPem, sans.identity, input.nodeId, 397, input.now ?? /* @__PURE__ */ new Date()),
750
938
  caCertPem: ca.certPem,
@@ -805,6 +993,18 @@ Object.defineProperty(exports, "MAX_LEAF_VALIDITY_DAYS", {
805
993
  return MAX_LEAF_VALIDITY_DAYS;
806
994
  }
807
995
  });
996
+ Object.defineProperty(exports, "PERMITTED_DNS_SUFFIXES", {
997
+ enumerable: true,
998
+ get: function() {
999
+ return PERMITTED_DNS_SUFFIXES;
1000
+ }
1001
+ });
1002
+ Object.defineProperty(exports, "PERMITTED_IP_RANGES", {
1003
+ enumerable: true,
1004
+ get: function() {
1005
+ return PERMITTED_IP_RANGES;
1006
+ }
1007
+ });
808
1008
  Object.defineProperty(exports, "SERVER_AUTH_OID", {
809
1009
  enumerable: true,
810
1010
  get: function() {
@@ -823,6 +1023,12 @@ Object.defineProperty(exports, "agentHttpsPaths", {
823
1023
  return agentHttpsPaths;
824
1024
  }
825
1025
  });
1026
+ Object.defineProperty(exports, "buildNameConstraintSet", {
1027
+ enumerable: true,
1028
+ get: function() {
1029
+ return buildNameConstraintSet;
1030
+ }
1031
+ });
826
1032
  Object.defineProperty(exports, "certificateNames", {
827
1033
  enumerable: true,
828
1034
  get: function() {
@@ -877,6 +1083,12 @@ Object.defineProperty(exports, "installAgentHttps", {
877
1083
  return installAgentHttps;
878
1084
  }
879
1085
  });
1086
+ Object.defineProperty(exports, "isConstrainedCa", {
1087
+ enumerable: true,
1088
+ get: function() {
1089
+ return isConstrainedCa;
1090
+ }
1091
+ });
880
1092
  Object.defineProperty(exports, "isContainerBridgeAddress", {
881
1093
  enumerable: true,
882
1094
  get: function() {
@@ -925,12 +1137,30 @@ Object.defineProperty(exports, "pemDigest", {
925
1137
  return pemDigest;
926
1138
  }
927
1139
  });
1140
+ Object.defineProperty(exports, "permitsIdentity", {
1141
+ enumerable: true,
1142
+ get: function() {
1143
+ return permitsIdentity;
1144
+ }
1145
+ });
1146
+ Object.defineProperty(exports, "permitsName", {
1147
+ enumerable: true,
1148
+ get: function() {
1149
+ return permitsName;
1150
+ }
1151
+ });
928
1152
  Object.defineProperty(exports, "pinnedHttpsCaFingerprint", {
929
1153
  enumerable: true,
930
1154
  get: function() {
931
1155
  return pinnedHttpsCaFingerprint;
932
1156
  }
933
1157
  });
1158
+ Object.defineProperty(exports, "readNameConstraints", {
1159
+ enumerable: true,
1160
+ get: function() {
1161
+ return readNameConstraints;
1162
+ }
1163
+ });
934
1164
  Object.defineProperty(exports, "reasonRequiresNewCa", {
935
1165
  enumerable: true,
936
1166
  get: function() {
@@ -1,4 +1,4 @@
1
- import { a as KeyUsagesExtension, c as Pkcs10CertificateRequestGenerator, d as X509Certificate$1, f as X509CertificateGenerator, g as backdatedNotBefore, i as KeyUsageFlags, l as SubjectAlternativeNameExtension, n as BasicConstraintsExtension, o as PemConverter, p as cryptoProvider, r as ExtendedKeyUsageExtension, s as Pkcs10CertificateRequest, t as AuthorityKeyIdentifierExtension, u as SubjectKeyIdentifierExtension } from "./x509.es-CDvj67p1.mjs";
1
+ import { S as backdatedNotBefore, _ as NameConstraints, a as KeyUsageFlags, c as Pkcs10CertificateRequest, d as SubjectKeyIdentifierExtension, f as X509Certificate$1, g as GeneralSubtrees, h as GeneralSubtree, i as Extension, l as Pkcs10CertificateRequestGenerator, m as cryptoProvider, n as BasicConstraintsExtension, o as KeyUsagesExtension, p as X509CertificateGenerator, r as ExtendedKeyUsageExtension, s as PemConverter, t as AuthorityKeyIdentifierExtension, u as SubjectAlternativeNameExtension, v as GeneralName, x as AsnConvert } from "./x509.es-DK__ETIe.mjs";
2
2
  import { X509Certificate, createHash, createPrivateKey, randomBytes, webcrypto } from "node:crypto";
3
3
  import { writeOwnerOnlyFileSync } from "@camstack/types/node";
4
4
  import { isIP } from "node:net";
@@ -99,6 +99,188 @@ function getProcessAgentHttpsNames() {
99
99
  return value !== null && typeof value === "object" && typeof Reflect.get(value, "takenByOthers") === "function" ? value : null;
100
100
  }
101
101
  //#endregion
102
+ //#region src/tls/cert-name-constraints.ts
103
+ /**
104
+ * Name constraints of the hub's generated local CA (D873).
105
+ *
106
+ * An unconstrained private CA vouches for EVERY site on the internet to any
107
+ * device that trusts it, so a leaked CA key would let its holder impersonate
108
+ * a bank. The generated CA therefore carries a CRITICAL `nameConstraints`
109
+ * extension (RFC 5280 4.2.1.10) that limits what it can ever certify to the
110
+ * private address space and local-only names. A verifier that does not
111
+ * understand a critical extension must reject the certificate, so the
112
+ * constraint cannot be silently ignored by a conforming client.
113
+ *
114
+ * Pure: no filesystem, no clock. `cert-issue.ts` embeds the extension,
115
+ * `cert-manager.ts` filters leaf names through {@link permitsIdentity}.
116
+ */
117
+ /** `id-ce-nameConstraints`. */
118
+ var NAME_CONSTRAINTS_OID = "2.5.29.30";
119
+ /** The address space the generated CA may certify. 100.64.0.0/10 is CGNAT, which Tailscale hands out. Loopback is for the hub's own `127.0.0.1` / `::1` SANs. */
120
+ var PERMITTED_IP_RANGES = [
121
+ "10.0.0.0/8",
122
+ "172.16.0.0/12",
123
+ "192.168.0.0/16",
124
+ "100.64.0.0/10",
125
+ "127.0.0.0/8",
126
+ "fc00::/7",
127
+ "::1/128"
128
+ ];
129
+ /**
130
+ * Local-only DNS suffixes: none of them is delegated in public DNS. A leading
131
+ * dot permits every name under the suffix and not the suffix itself.
132
+ */
133
+ var PERMITTED_DNS_SUFFIXES = [
134
+ ".local",
135
+ ".lan",
136
+ ".home.arpa",
137
+ ".internal",
138
+ ".localdomain",
139
+ "localhost"
140
+ ];
141
+ var LABEL_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/i;
142
+ /** A name that cannot collide with public DNS: a single label, or under a local-only suffix. */
143
+ function isLocalOnlyHostname(name) {
144
+ const lower = name.toLowerCase();
145
+ if (lower.length === 0 || lower.length > 253) return false;
146
+ if (!lower.includes(".")) return LABEL_RE.test(lower);
147
+ if (!PERMITTED_DNS_SUFFIXES.some((suffix) => suffix.startsWith(".") && lower.endsWith(suffix))) return false;
148
+ return lower.split(".").every((label) => LABEL_RE.test(label));
149
+ }
150
+ /**
151
+ * The constraint set for a new CA. `hostnames` are the hub's own local-only
152
+ * names at creation time (a single-label OS hostname has no suffix to hide
153
+ * under), added as exact entries; anything that could collide with public DNS
154
+ * is dropped here, never constrained in.
155
+ */
156
+ function buildNameConstraintSet(hostnames) {
157
+ const dns = new Set(PERMITTED_DNS_SUFFIXES);
158
+ for (const name of hostnames) {
159
+ const lower = name.trim().toLowerCase();
160
+ if (isIP(lower) === 0 && isLocalOnlyHostname(lower)) dns.add(lower);
161
+ }
162
+ return {
163
+ ipRanges: PERMITTED_IP_RANGES,
164
+ dnsNames: [...dns],
165
+ critical: true
166
+ };
167
+ }
168
+ /** The `nameConstraints` extension for a CA: permitted subtrees only, marked critical. */
169
+ function createNameConstraintsExtension(set) {
170
+ const subtrees = [...set.ipRanges.map((range) => new GeneralSubtree({ base: new GeneralName({ iPAddress: range }) })), ...set.dnsNames.map((name) => new GeneralSubtree({ base: new GeneralName({ dNSName: name }) }))];
171
+ const value = AsnConvert.serialize(new NameConstraints({ permittedSubtrees: new GeneralSubtrees(subtrees) }));
172
+ return new Extension(NAME_CONSTRAINTS_OID, set.critical, value);
173
+ }
174
+ /** The CA's constraint set, or `null` when the certificate carries no `nameConstraints`. */
175
+ function readNameConstraints(cert) {
176
+ const ext = cert.extensions.find((e) => e.type === NAME_CONSTRAINTS_OID);
177
+ if (ext === void 0) return null;
178
+ const parsed = AsnConvert.parse(ext.value, NameConstraints);
179
+ const ipRanges = [];
180
+ const dnsNames = [];
181
+ for (const subtree of parsed.permittedSubtrees ?? []) {
182
+ if (subtree.base.iPAddress !== void 0) ipRanges.push(subtree.base.iPAddress);
183
+ if (subtree.base.dNSName !== void 0) dnsNames.push(subtree.base.dNSName.toLowerCase());
184
+ }
185
+ return {
186
+ ipRanges,
187
+ dnsNames,
188
+ critical: ext.critical
189
+ };
190
+ }
191
+ /**
192
+ * True when a CA is safe to keep as the hub's anchor: constrained to a
193
+ * non-empty permitted set AND marked critical. A non-critical constraint is
194
+ * ignored by clients that do not know the extension, which is the same as none.
195
+ */
196
+ function isConstrainedCa(cert) {
197
+ const set = readNameConstraints(cert);
198
+ return set !== null && set.critical && (set.ipRanges.length > 0 || set.dnsNames.length > 0);
199
+ }
200
+ function ipv4ToBigInt(address) {
201
+ return address.split(".").reduce((acc, octet) => acc << 8n | BigInt(Number(octet)), 0n);
202
+ }
203
+ function ipv6ToBigInt(address) {
204
+ const mapped = /^(.*:)(\d+\.\d+\.\d+\.\d+)$/.exec(address);
205
+ let text = address;
206
+ if (mapped !== null) {
207
+ const v4 = ipv4ToBigInt(mapped[2] ?? "0.0.0.0");
208
+ text = `${mapped[1] ?? ""}${(v4 >> 16n & 65535n).toString(16)}:${(v4 & 65535n).toString(16)}`;
209
+ }
210
+ const halves = text.split("::");
211
+ if (halves.length > 2) return null;
212
+ const head = (halves[0] ?? "") === "" ? [] : (halves[0] ?? "").split(":");
213
+ const tail = halves.length === 2 && (halves[1] ?? "") !== "" ? (halves[1] ?? "").split(":") : [];
214
+ const fill = halves.length === 2 ? 8 - head.length - tail.length : 0;
215
+ if (fill < 0 || halves.length === 1 && head.length !== 8) return null;
216
+ return [
217
+ ...head,
218
+ ...Array(fill).fill("0"),
219
+ ...tail
220
+ ].reduce((acc, group) => acc << 16n | BigInt(Number.parseInt(group, 16)), 0n);
221
+ }
222
+ function toBigInt(address) {
223
+ const family = isIP(address);
224
+ if (family === 4) return {
225
+ value: ipv4ToBigInt(address),
226
+ bits: 32
227
+ };
228
+ if (family === 6) {
229
+ const value = ipv6ToBigInt(address);
230
+ return value === null ? null : {
231
+ value,
232
+ bits: 128
233
+ };
234
+ }
235
+ return null;
236
+ }
237
+ function ipInRange(address, range) {
238
+ const [base, prefixText] = range.split("/");
239
+ if (base === void 0 || prefixText === void 0) return false;
240
+ const target = toBigInt(address);
241
+ const net = toBigInt(base);
242
+ const prefix = Number(prefixText);
243
+ if (target === null || net === null || target.bits !== net.bits) return false;
244
+ if (!Number.isInteger(prefix) || prefix < 0 || prefix > net.bits) return false;
245
+ const shift = BigInt(net.bits - prefix);
246
+ return target.value >> shift === net.value >> shift;
247
+ }
248
+ /** RFC 5280 DNS matching: a leading dot is a strict subtree, a bare name is itself and everything under it. */
249
+ function dnsPermitted(name, constraint) {
250
+ const lower = name.toLowerCase();
251
+ if (constraint.startsWith(".")) return lower.endsWith(constraint);
252
+ return lower === constraint || lower.endsWith(`.${constraint}`);
253
+ }
254
+ /** Is this SAN value (an IP literal or a DNS name) inside the permitted set? */
255
+ function permitsName(set, value) {
256
+ if (isIP(value) !== 0) return set.ipRanges.some((range) => ipInRange(value, range));
257
+ return set.dnsNames.some((constraint) => dnsPermitted(value, constraint));
258
+ }
259
+ /**
260
+ * The part of an identity the CA is allowed to certify. A leaf naming
261
+ * anything outside the constraints makes the WHOLE chain fail validation in
262
+ * OpenSSL, so such names are dropped (and reported), never signed. The
263
+ * required lists shrink the same way: otherwise a name the CA cannot certify
264
+ * would count as a permanent SAN gap and reissue the leaf on every boot.
265
+ */
266
+ function permitsIdentity(set, identity) {
267
+ const dropped = /* @__PURE__ */ new Set();
268
+ const keep = (values) => values.filter((value) => {
269
+ if (permitsName(set, value)) return true;
270
+ dropped.add(value);
271
+ return false;
272
+ });
273
+ return {
274
+ identity: {
275
+ dnsNames: keep(identity.dnsNames),
276
+ ipAddresses: keep(identity.ipAddresses),
277
+ requiredDnsNames: keep(identity.requiredDnsNames),
278
+ requiredIpAddresses: keep(identity.requiredIpAddresses)
279
+ },
280
+ dropped: [...dropped]
281
+ };
282
+ }
283
+ //#endregion
102
284
  //#region src/tls/cert-evaluation.ts
103
285
  /**
104
286
  * The regeneration rule (D227), as a pure function.
@@ -127,7 +309,8 @@ var REASONS_REQUIRING_NEW_CA = [
127
309
  "missing",
128
310
  "unreadable",
129
311
  "no-local-ca",
130
- "ca-expiring"
312
+ "ca-expiring",
313
+ "ca-unconstrained"
131
314
  ];
132
315
  function reasonRequiresNewCa(reason) {
133
316
  return REASONS_REQUIRING_NEW_CA.includes(reason);
@@ -139,6 +322,14 @@ function parse(pem) {
139
322
  return null;
140
323
  }
141
324
  }
325
+ /** A root without a CRITICAL name constraint (every pre-D873 install) can vouch for any site. */
326
+ function caIsUnconstrained(caPem) {
327
+ try {
328
+ return !isConstrainedCa(new X509Certificate$1(caPem));
329
+ } catch {
330
+ return true;
331
+ }
332
+ }
142
333
  function daysBetween(from, to) {
143
334
  return (to.getTime() - from.getTime()) / MS_PER_DAY$1;
144
335
  }
@@ -169,6 +360,7 @@ function evaluateExistingCert(input) {
169
360
  const ca = parse(input.caPem);
170
361
  if (leaf === null || ca === null) return "unreadable";
171
362
  if (daysBetween(input.now, new Date(ca.validTo)) < 427) return "ca-expiring";
363
+ if (caIsUnconstrained(input.caPem)) return "ca-unconstrained";
172
364
  if (!keyMatches(leaf, input.keyPem)) return "key-mismatch";
173
365
  if (daysBetween(input.now, new Date(leaf.validTo)) < 30) return "expiring";
174
366
  if (!hasServerAuthEku(leaf)) return "missing-server-auth-eku";
@@ -381,13 +573,18 @@ function subjectAltNames(identity) {
381
573
  * A local root: 10 years, `pathlen:0` so it can sign leaves and nothing
382
574
  * else, and **no EKU** — Apple rejects an anchor asserting
383
575
  * `anyExtendedKeyUsage`, and a root with no EKU constrains nothing.
576
+ *
577
+ * It carries a CRITICAL `nameConstraints` extension (D873): private address
578
+ * ranges and local-only DNS names, plus `hostnames` (the hub's own single-label
579
+ * names at creation). A leaked key therefore cannot vouch for a public site.
384
580
  */
385
- async function generateCa(now) {
581
+ async function generateCa(now, hostnames = []) {
386
582
  const keys = await webcrypto.subtle.generateKey(SIGNING_ALGORITHM, true, ["sign", "verify"]);
387
583
  const extensions = [
388
584
  new BasicConstraintsExtension(true, 0, true),
389
585
  new KeyUsagesExtension(KeyUsageFlags.keyCertSign | KeyUsageFlags.cRLSign, true),
390
- await SubjectKeyIdentifierExtension.create(keys.publicKey)
586
+ await SubjectKeyIdentifierExtension.create(keys.publicKey),
587
+ createNameConstraintsExtension(buildNameConstraintSet(hostnames))
391
588
  ];
392
589
  const cert = await X509CertificateGenerator.createSelfSigned({
393
590
  serialNumber: freshSerial(),
@@ -650,14 +847,6 @@ function describeHttpsLeaf(certPem) {
650
847
  notAfter: Date.parse(x509.validTo)
651
848
  };
652
849
  }
653
- var LOCAL_SUFFIXES = [
654
- ".local",
655
- ".lan",
656
- ".home.arpa",
657
- ".internal",
658
- ".localdomain"
659
- ];
660
- var LABEL_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/i;
661
850
  /** Private-network IPv4/IPv6: RFC 1918, CGNAT (Tailscale), link-local, ULA. */
662
851
  function isPrivateAddress(address) {
663
852
  const family = isIP(address);
@@ -675,14 +864,6 @@ function isPrivateAddress(address) {
675
864
  function isContainerBridgeAddress(address) {
676
865
  return /^172\.17\.\d{1,3}\.\d{1,3}$/.test(address);
677
866
  }
678
- /** A name that cannot collide with public DNS. */
679
- function isLocalOnlyHostname(name) {
680
- const lower = name.toLowerCase();
681
- if (lower.length === 0 || lower.length > 253) return false;
682
- if (!lower.includes(".")) return LABEL_RE.test(lower);
683
- if (!LOCAL_SUFFIXES.some((suffix) => lower.endsWith(suffix))) return false;
684
- return lower.split(".").every((label) => LABEL_RE.test(label));
685
- }
686
867
  /**
687
868
  * The names the hub will sign for `nodeId`: its node id and the address of its live mesh
688
869
  * socket - nothing the agent says about itself. `excluded` holds every name already taken
@@ -735,14 +916,21 @@ async function issueAgentHttpsLeaf(input) {
735
916
  if (!existsSync(caCertPath) || !existsSync(caKeyPath)) throw new Error("the hub has no local CA to sign with (no tls/camstack-ca.crt + key)");
736
917
  const excluded = new Set(input.takenByOthers ?? []);
737
918
  if (existsSync(hubLeafPath)) for (const name of certificateNames(readFileSync(hubLeafPath, "utf8"))) excluded.add(name);
738
- const sans = acceptAgentSans({
919
+ const ca = await loadCa(readFileSync(caCertPath, "utf8"), readFileSync(caKeyPath, "utf8"));
920
+ const policy = acceptAgentSans({
739
921
  nodeId: input.nodeId,
740
922
  observedAddress: input.observedAddress,
741
923
  excluded,
742
924
  ...input.reportedNames !== void 0 ? { reported: input.reportedNames } : {}
743
925
  });
926
+ const constraints = readNameConstraints(ca.cert);
927
+ const constrained = constraints === null ? null : permitsIdentity(constraints, policy.identity);
928
+ const sans = constrained === null ? policy : {
929
+ identity: constrained.identity,
930
+ accepted: policy.accepted.filter((name) => !constrained.dropped.includes(name)),
931
+ rejected: [...policy.rejected, ...constrained.dropped.map((name) => `${name} (outside the local CA's name constraints)`)]
932
+ };
744
933
  if (sans.accepted.length === 0) throw new Error(`no name of node '${input.nodeId}' can be certified (${sans.rejected.join("; ") || "nothing offered"})`);
745
- const ca = await loadCa(readFileSync(caCertPath, "utf8"), readFileSync(caKeyPath, "utf8"));
746
934
  return {
747
935
  certPem: await issueLeafFromCsr(ca, input.csrPem, sans.identity, input.nodeId, 397, input.now ?? /* @__PURE__ */ new Date()),
748
936
  caCertPem: ca.certPem,
@@ -755,4 +943,4 @@ function pemDigest(pem) {
755
943
  return createHash("sha256").update(pem).digest("hex").slice(0, 16);
756
944
  }
757
945
  //#endregion
758
- export { AgentHttpsNames as A, LEAF_RENEWAL_WINDOW_DAYS as C, reasonRequiresNewCa as D, evaluateExistingCert as E, setProcessAgentHttpsNames as M, AGENT_HTTPS_NAMES_COLLECTION as O, CA_VALIDITY_DAYS as S, SERVER_AUTH_OID as T, generateCa as _, collectAgentNames as a, collectCertIdentity as b, installAgentHttps as c, isPrivateAddress as d, issueAgentHttpsLeaf as f, CA_COMMON_NAME as g, pinnedHttpsCaFingerprint as h, certificateNames as i, getProcessAgentHttpsNames as j, AGENT_HTTPS_NAMES_COLUMNS as k, isContainerBridgeAddress as l, pemDigest as m, acceptAgentSans as n, createAgentHttpsRequest as o, loadAgentHttps as p, agentHttpsPaths as r, describeHttpsLeaf as s, AgentHttpsInstallError as t, isLocalOnlyHostname as u, issueLeaf as v, MAX_LEAF_VALIDITY_DAYS as w, systemHostFacts as x, loadCa as y };
946
+ export { isConstrainedCa as A, MAX_LEAF_VALIDITY_DAYS as C, PERMITTED_DNS_SUFFIXES as D, reasonRequiresNewCa as E, AGENT_HTTPS_NAMES_COLLECTION as F, AGENT_HTTPS_NAMES_COLUMNS as I, AgentHttpsNames as L, permitsIdentity as M, permitsName as N, PERMITTED_IP_RANGES as O, readNameConstraints as P, getProcessAgentHttpsNames as R, LEAF_RENEWAL_WINDOW_DAYS as S, evaluateExistingCert as T, issueLeaf as _, collectAgentNames as a, systemHostFacts as b, installAgentHttps as c, issueAgentHttpsLeaf as d, loadAgentHttps as f, generateCa as g, CA_COMMON_NAME as h, certificateNames as i, isLocalOnlyHostname as j, buildNameConstraintSet as k, isContainerBridgeAddress as l, pinnedHttpsCaFingerprint as m, acceptAgentSans as n, createAgentHttpsRequest as o, pemDigest as p, agentHttpsPaths as r, describeHttpsLeaf as s, AgentHttpsInstallError as t, isPrivateAddress as u, loadCa as v, SERVER_AUTH_OID as w, CA_VALIDITY_DAYS as x, collectCertIdentity as y, setProcessAgentHttpsNames as z };