wire-mesh 3.2.1 → 3.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.
package/dist/server.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { createNodeWebSocketTransport } from "./adapters/node-websocket-transport.mjs";
3
- import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs";
4
4
  import { dirname, extname, join, normalize, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { createRelayHub } from "wire-mesh-core/domain/relay-hub";
@@ -12,8 +12,15 @@ import { randomBytes } from "node:crypto";
12
12
  import { networkInterfaces } from "node:os";
13
13
  import { connectionFromByteStream } from "wire-mesh-core/adapters/byte-stream-connection";
14
14
  import { formatPinnedAddress } from "wire-mesh-core/domain/pinned-address";
15
+ import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage";
15
16
  import "reflect-metadata";
16
17
  import { BasicConstraintsExtension, ExtendedKeyUsage, ExtendedKeyUsageExtension, KeyUsageFlags, KeyUsagesExtension, X509CertificateGenerator } from "@peculiar/x509";
18
+ //#region src/adapters/certificate-limits.ts
19
+ const MILLISECONDS_PER_SECOND = 1e3;
20
+ const MILLISECONDS_PER_DAY = 86400 * MILLISECONDS_PER_SECOND;
21
+ /** Slack under the maximum, so clock skew between the node and a browser cannot push the certificate over the limit. */
22
+ const PINNED_CERTIFICATE_LIFETIME_MS = 14 * MILLISECONDS_PER_DAY - 1 * MILLISECONDS_PER_DAY;
23
+ //#endregion
17
24
  //#region src/cli-options.ts
18
25
  /** The address the server binds when --bind is not given. */
19
26
  const DEFAULT_BIND_ADDRESS = "0.0.0.0:8787";
@@ -46,6 +53,16 @@ const FLAGS = {
46
53
  valueName: "host:port",
47
54
  description: "Also serve WebTransport on this UDP address, with a self-signed certificate renewed on a schedule that a browser accepts by pinning its hash, and print the address to give a console. Needs the optional @fails-components/webtransport dependency. Off unless given."
48
55
  },
56
+ "state-dir": {
57
+ type: "string",
58
+ valueName: "path",
59
+ description: "Directory to keep the WebTransport certificates in, private keys included, so a restart serves the same certificates and the addresses already handed out stay valid. Created readable by this user only. Without it the certificates live in memory and a restart starts a new set. Needs --webtransport."
60
+ },
61
+ "certificate-lifetime": {
62
+ type: "string",
63
+ valueName: "seconds",
64
+ description: "How long each WebTransport certificate is valid, in seconds. The node rotates to the next certificate every half lifetime, ending the sessions open on the server it replaces, so a shorter lifetime rotates more often. At most the WebTransport limit for a pinned certificate, which is also the default. Needs --webtransport."
65
+ },
49
66
  help: {
50
67
  type: "boolean",
51
68
  short: "h",
@@ -79,6 +96,12 @@ function helpText() {
79
96
  ...rows.map((row) => ` ${row.label.padEnd(labelWidth)} ${row.description}`)
80
97
  ].join("\n");
81
98
  }
99
+ function validatedCertificateLifetimeMs(seconds) {
100
+ if (seconds === void 0) return;
101
+ const lifetimeMs = Number(seconds) * MILLISECONDS_PER_SECOND;
102
+ if (!/^\d+$/.test(seconds) || lifetimeMs === 0 || lifetimeMs > 11232e5) throw new CliUsageError(`--certificate-lifetime expects a whole number of seconds from 1 to ${String(PINNED_CERTIFICATE_LIFETIME_MS / MILLISECONDS_PER_SECOND)}, got "${seconds}"`);
103
+ return lifetimeMs;
104
+ }
82
105
  function validatedBindAddress(flag, address) {
83
106
  const separator = address.lastIndexOf(":");
84
107
  const host = address.slice(0, separator);
@@ -105,6 +128,8 @@ function parseCliArguments(argv) {
105
128
  const certPath = values["tls-cert"];
106
129
  const keyPath = values["tls-key"];
107
130
  if (certPath === void 0 !== (keyPath === void 0)) throw new CliUsageError("--tls-cert and --tls-key must be given together");
131
+ if (values["state-dir"] !== void 0 && values.webtransport === void 0) throw new CliUsageError("--state-dir needs --webtransport");
132
+ if (values["certificate-lifetime"] !== void 0 && values.webtransport === void 0) throw new CliUsageError("--certificate-lifetime needs --webtransport");
108
133
  return {
109
134
  kind: "serve",
110
135
  bindAddress: validatedBindAddress("bind", values.bind),
@@ -113,7 +138,9 @@ function parseCliArguments(argv) {
113
138
  keyPath
114
139
  },
115
140
  mailboxDir: values["mailbox-dir"],
116
- webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport)
141
+ webTransportAddress: values.webtransport === void 0 ? void 0 : validatedBindAddress("webtransport", values.webtransport),
142
+ stateDir: values["state-dir"],
143
+ certificateLifetimeMs: validatedCertificateLifetimeMs(values["certificate-lifetime"])
117
144
  };
118
145
  }
119
146
  //#endregion
@@ -127,13 +154,8 @@ const nodeMailboxLimits = {
127
154
  };
128
155
  //#endregion
129
156
  //#region src/adapters/pinned-certificate.ts
130
- const MILLISECONDS_PER_DAY = 864e5;
131
- const MAX_PINNED_CERTIFICATE_LIFETIME_DAYS = 14;
132
- const CLOCK_SKEW_ALLOWANCE_DAYS = 1;
133
157
  const SERIAL_NUMBER_BYTES = 16;
134
158
  const PEM_LINE_LENGTH = 64;
135
- /** Slack under the maximum, so clock skew between the node and a browser cannot push the certificate over the limit. */
136
- const PINNED_CERTIFICATE_LIFETIME_MS = MAX_PINNED_CERTIFICATE_LIFETIME_DAYS * MILLISECONDS_PER_DAY - CLOCK_SKEW_ALLOWANCE_DAYS * MILLISECONDS_PER_DAY;
137
159
  const SIGNING_ALGORITHM = {
138
160
  name: "ECDSA",
139
161
  namedCurve: "P-256",
@@ -146,15 +168,15 @@ function toPem(label, der) {
146
168
  return `-----BEGIN ${label}-----\n${(Buffer.from(der).toString("base64").match(new RegExp(`.{1,${String(PEM_LINE_LENGTH)}}`, "g")) ?? []).join("\n")}\n-----END ${label}-----\n`;
147
169
  }
148
170
  /**
149
- * Mints an ECDSA P-256 certificate valid from `now` for PINNED_CERTIFICATE_LIFETIME_MS. Clients pin it by hash rather than by name, so it carries only a common name.
171
+ * Mints an ECDSA P-256 certificate valid from `notBefore` for `lifetimeMs` (PINNED_CERTIFICATE_LIFETIME_MS unless a test asks for less). Clients pin it by hash rather than by name, so it carries only a common name.
150
172
  */
151
- async function mintPinnedCertificate(now) {
173
+ async function mintPinnedCertificate(notBefore, lifetimeMs = PINNED_CERTIFICATE_LIFETIME_MS) {
152
174
  const keys = await crypto.subtle.generateKey(SIGNING_ALGORITHM, true, ["sign", "verify"]);
153
- const notAfter = new Date(now.getTime() + PINNED_CERTIFICATE_LIFETIME_MS);
175
+ const notAfter = new Date(notBefore.getTime() + lifetimeMs);
154
176
  const certificate = await X509CertificateGenerator.createSelfSigned({
155
177
  serialNumber: toHex(crypto.getRandomValues(new Uint8Array(SERIAL_NUMBER_BYTES))),
156
178
  name: "CN=wire-mesh",
157
- notBefore: now,
179
+ notBefore,
158
180
  notAfter,
159
181
  signingAlgorithm: SIGNING_ALGORITHM,
160
182
  keys,
@@ -169,15 +191,78 @@ async function mintPinnedCertificate(now) {
169
191
  certificatePem: certificate.toString("pem"),
170
192
  privateKeyPem: toPem("PRIVATE KEY", await crypto.subtle.exportKey("pkcs8", keys.privateKey)),
171
193
  sha256Hex: toHex(new Uint8Array(digest)),
194
+ notBefore,
172
195
  notAfter
173
196
  };
174
197
  }
198
+ /** A certificate is served for half its lifetime, so each successor is already valid, and its hash already listed, well before it takes over. */
199
+ const SERVING_FRACTION = .5;
200
+ const STORAGE_KEY = "webtransport-certificates";
201
+ function isStoredCertificate(value) {
202
+ if (typeof value !== "object" || value === null) return false;
203
+ return "certificatePem" in value && typeof value.certificatePem === "string" && "privateKeyPem" in value && typeof value.privateKeyPem === "string" && "sha256Hex" in value && typeof value.sha256Hex === "string" && "notBefore" in value && typeof value.notBefore === "string" && "notAfter" in value && typeof value.notAfter === "string";
204
+ }
205
+ function fromStored(stored) {
206
+ return {
207
+ certificatePem: stored.certificatePem,
208
+ privateKeyPem: stored.privateKeyPem,
209
+ sha256Hex: stored.sha256Hex,
210
+ notBefore: new Date(stored.notBefore),
211
+ notAfter: new Date(stored.notAfter)
212
+ };
213
+ }
214
+ function toStored(certificate) {
215
+ return {
216
+ certificatePem: certificate.certificatePem,
217
+ privateKeyPem: certificate.privateKeyPem,
218
+ sha256Hex: certificate.sha256Hex,
219
+ notBefore: certificate.notBefore.toISOString(),
220
+ notAfter: certificate.notAfter.toISOString()
221
+ };
222
+ }
223
+ async function readStored(storage) {
224
+ const bytes = await storage.get(STORAGE_KEY);
225
+ if (bytes === void 0) return [];
226
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
227
+ if (!Array.isArray(parsed) || !parsed.every(isStoredCertificate)) throw new Error(`the stored ${STORAGE_KEY} are not in the expected shape`);
228
+ return parsed.map(fromStored);
229
+ }
230
+ async function writeStored(storage, certificates) {
231
+ await storage.set(STORAGE_KEY, new TextEncoder().encode(JSON.stringify(certificates.map(toStored))));
232
+ }
233
+ /**
234
+ * Keeps a rolling schedule of pinned certificates in `storage`: the one serving now and enough successors that an address lists ADVERTISED_CERTIFICATES of them. Each successor starts one serving period after its predecessor, so it is valid, and pinned, before it takes over.
235
+ *
236
+ * `advance` brings the schedule up to `now`: certificates that have finished serving are dropped, missing successors are minted, and if nothing stored is serving (a first run, or a node that was down past every stored certificate) a certificate valid from `now` starts a new schedule. What it returns is written back, so a restart resumes the same certificates and the addresses already handed out stay valid.
237
+ */
238
+ async function advanceCertificateSchedule(storage, now, options = {}) {
239
+ const lifetimeMs = options.lifetimeMs ?? 11232e5;
240
+ const servingMs = lifetimeMs * SERVING_FRACTION;
241
+ const stillValid = (await readStored(storage)).filter((certificate) => certificate.notAfter.getTime() > now.getTime());
242
+ const startedIndex = stillValid.findLastIndex((certificate) => certificate.notBefore.getTime() <= now.getTime());
243
+ const certificates = startedIndex === -1 ? [await mintPinnedCertificate(now, lifetimeMs)] : stillValid.slice(startedIndex);
244
+ while (certificates.length < 4) {
245
+ const last = certificates[certificates.length - 1];
246
+ if (last === void 0) throw new Error("the certificate schedule is empty");
247
+ certificates.push(await mintPinnedCertificate(new Date(last.notBefore.getTime() + servingMs), lifetimeMs));
248
+ }
249
+ await writeStored(storage, certificates);
250
+ const [serving, next] = certificates;
251
+ if (serving === void 0 || next === void 0) throw new Error("the certificate schedule is empty");
252
+ return {
253
+ serving,
254
+ advertised: certificates.slice(0, 3),
255
+ rotatesAt: next.notBefore
256
+ };
257
+ }
175
258
  //#endregion
176
259
  //#region src/adapters/webtransport-transport.ts
177
260
  /** The path a client opens its WebTransport session on. */
178
261
  const WEBTRANSPORT_PATH = "/wire-mesh";
179
- const RENEWAL_DELAY_MS = PINNED_CERTIFICATE_LIFETIME_MS * .5;
180
262
  const SECRET_BYTES = 32;
263
+ /** The application close code and reason sent when a certificate rotation ends a session; the code is arbitrary, and 0 is what a normal close carries. */
264
+ const SESSION_CLOSE_CODE = 0;
265
+ const ROTATION_CLOSE_REASON = "certificate rotated";
181
266
  const WILDCARD_HOSTS = ["0.0.0.0", "::"];
182
267
  /** The hosts a client can dial for a listener bound to `host`: the host itself, or for a wildcard every non-internal IPv4 address of this machine. */
183
268
  function reachableHosts(host) {
@@ -202,7 +287,7 @@ async function loadHttp3Server() {
202
287
  }
203
288
  }
204
289
  function isIncomingSession(value) {
205
- return typeof value === "object" && value !== null && "ready" in value && value.ready instanceof Promise && "incomingBidirectionalStreams" in value && value.incomingBidirectionalStreams instanceof ReadableStream;
290
+ return typeof value === "object" && value !== null && "ready" in value && value.ready instanceof Promise && "closed" in value && value.closed instanceof Promise && "close" in value && typeof value.close === "function" && "incomingBidirectionalStreams" in value && value.incomingBidirectionalStreams instanceof ReadableStream;
206
291
  }
207
292
  /**
208
293
  * A WebTransport Transport: each session carries one connection, on the first bidirectional stream the client opens, framed as length-prefixed CBOR (core's byte-stream-connection). A server binds a UDP port and serves a self-signed ECDSA certificate it renews on a schedule, which a browser accepts by pinning its hash (`serverCertificateHashes`) with no certificate authority involved.
@@ -220,53 +305,99 @@ function createWebTransportTransport(options = {}) {
220
305
  },
221
306
  async listen(address, onConnection) {
222
307
  const Http3ServerClass = await loadHttp3Server();
308
+ const storage = options.storage ?? createMemoryStorage();
309
+ const scheduleOptions = options.certificateLifetimeMs === void 0 ? {} : { lifetimeMs: options.certificateLifetimeMs };
223
310
  const lastColon = address.lastIndexOf(":");
224
311
  const host = address.slice(0, lastColon);
225
- const port = Number(address.slice(lastColon + 1));
226
- let certificate = await mintPinnedCertificate(now());
227
- const server = new Http3ServerClass({
228
- host,
229
- port,
230
- secret: randomBytes(SECRET_BYTES).toString("hex"),
231
- cert: certificate.certificatePem,
232
- privKey: certificate.privateKeyPem
233
- });
234
- server.startServer();
235
- await server.ready;
236
- const bound = server.address();
237
- if (bound === null) throw new Error("the WebTransport server bound no address");
238
- const boundAddress = `${host}:${String(bound.port)}`;
239
- const advertise = () => reachableHosts(host).map((reachable) => formatPinnedAddress(`${reachable}:${String(bound.port)}`, certificate.sha256Hex));
240
- (async () => {
241
- for await (const session of server.sessionStream(WEBTRANSPORT_PATH)) (async () => {
242
- if (!isIncomingSession(session)) throw new Error("the WebTransport server yielded a malformed session");
243
- await session.ready;
244
- const reader = session.incomingBidirectionalStreams.getReader();
245
- const first = await reader.read();
246
- reader.releaseLock();
247
- if (!first.done) onConnection(connectionFromByteStream(first.value));
312
+ let port = Number(address.slice(lastColon + 1));
313
+ let schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
314
+ /** Starts a server presenting the schedule's serving certificate. The package cannot change the certificate of a running HTTP/3 server (`updateCert` only reaches the HTTP/2 side), so a rotation replaces the server. */
315
+ const startServer = async () => {
316
+ const started = new Http3ServerClass({
317
+ host,
318
+ port,
319
+ secret: randomBytes(SECRET_BYTES).toString("hex"),
320
+ cert: schedule.serving.certificatePem,
321
+ privKey: schedule.serving.privateKeyPem
322
+ });
323
+ started.startServer();
324
+ await started.ready;
325
+ const bound = started.address();
326
+ if (bound === null) throw new Error("the WebTransport server bound no address");
327
+ port = bound.port;
328
+ const liveSessions = /* @__PURE__ */ new Set();
329
+ const drainState = { draining: false };
330
+ const endSession = (session) => {
331
+ session.close({
332
+ closeCode: SESSION_CLOSE_CODE,
333
+ reason: ROTATION_CLOSE_REASON
334
+ });
335
+ };
336
+ (async () => {
337
+ for await (const session of started.sessionStream(WEBTRANSPORT_PATH)) (async () => {
338
+ if (!isIncomingSession(session)) throw new Error("the WebTransport server yielded a malformed session");
339
+ await session.ready;
340
+ if (drainState.draining) {
341
+ endSession(session);
342
+ return;
343
+ }
344
+ liveSessions.add(session);
345
+ session.closed.catch(() => void 0).finally(() => {
346
+ liveSessions.delete(session);
347
+ });
348
+ const reader = session.incomingBidirectionalStreams.getReader();
349
+ const first = await reader.read();
350
+ reader.releaseLock();
351
+ if (!first.done) onConnection(connectionFromByteStream(first.value));
352
+ })().catch(reportError);
248
353
  })().catch(reportError);
249
- })().catch(reportError);
250
- const renewal = setInterval(() => {
251
- mintPinnedCertificate(now()).then((renewed) => {
252
- certificate = renewed;
253
- server.updateCert(renewed.certificatePem, renewed.privateKeyPem, false);
254
- options.onCertificateRenewed?.(advertise());
255
- }).catch(reportError);
256
- }, RENEWAL_DELAY_MS);
257
- renewal.unref();
354
+ return {
355
+ server: started,
356
+ drain: async () => {
357
+ drainState.draining = true;
358
+ const open = [...liveSessions];
359
+ for (const session of open) endSession(session);
360
+ await Promise.allSettled(open.map(async (session) => session.closed));
361
+ }
362
+ };
363
+ };
364
+ let running = await startServer();
365
+ const advertise = () => reachableHosts(host).map((reachable) => formatPinnedAddress(`${reachable}:${String(port)}`, schedule.advertised.map((certificate) => certificate.sha256Hex)));
366
+ const retire = async () => {
367
+ await running.drain();
368
+ running.server.stopServer();
369
+ await running.server.closed;
370
+ };
371
+ let rotation;
372
+ let closed = false;
373
+ const armRotation = () => {
374
+ rotation = setTimeout(() => {
375
+ (async () => {
376
+ schedule = await advanceCertificateSchedule(storage, now(), scheduleOptions);
377
+ await retire();
378
+ if (closed) return;
379
+ running = await startServer();
380
+ options.onCertificateRenewed?.(advertise());
381
+ armRotation();
382
+ })().catch(reportError);
383
+ }, Math.max(schedule.rotatesAt.getTime() - now().getTime(), 0));
384
+ rotation.unref();
385
+ };
386
+ armRotation();
258
387
  return {
259
- address: boundAddress,
388
+ get address() {
389
+ return `${host}:${String(port)}`;
390
+ },
260
391
  get advertisedAddresses() {
261
392
  return advertise();
262
393
  },
263
394
  close: async () => {
264
- clearInterval(renewal);
265
- server.stopServer();
266
- await server.closed;
395
+ closed = true;
396
+ clearTimeout(rotation);
397
+ await retire();
267
398
  },
268
399
  unref: () => {
269
- renewal.unref();
400
+ rotation?.unref();
270
401
  }
271
402
  };
272
403
  }
@@ -325,6 +456,8 @@ function healthResponse(announcer) {
325
456
  }
326
457
  const HTTP_OK = 200;
327
458
  const HTTP_NOT_FOUND = 404;
459
+ /** Owner read, write and search only: the state directory holds private keys. */
460
+ const STATE_DIR_MODE = 448;
328
461
  const HEALTH_PATH = "/health";
329
462
  const EXIT_FAILURE = 1;
330
463
  const EXIT_USAGE = 2;
@@ -400,7 +533,13 @@ async function main(argv) {
400
533
  });
401
534
  logOutput(`wire-mesh listening on ${tls ? "wss" : "ws"}://${listener.address}`);
402
535
  if (command.webTransportAddress !== void 0) {
536
+ if (command.stateDir !== void 0) mkdirSync(command.stateDir, {
537
+ recursive: true,
538
+ mode: STATE_DIR_MODE
539
+ });
403
540
  const webTransport = await createWebTransportTransport({
541
+ ...command.certificateLifetimeMs !== void 0 ? { certificateLifetimeMs: command.certificateLifetimeMs } : {},
542
+ ...command.stateDir !== void 0 ? { storage: createNodeFsStorage({ dir: command.stateDir }) } : {},
404
543
  onError: (error) => {
405
544
  logError(`wire-mesh: WebTransport: ${String(error)}`);
406
545
  },