@johnhenry/wsh 0.26.0 → 0.27.1

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/README.md CHANGED
@@ -220,11 +220,66 @@ await client.connect(url, {
220
220
  client writes no primer.
221
221
  - **`selfSigned: true`** generates (no dependency) an ECDSA P-256 certificate valid for 13 days -- a browser refuses a
222
222
  pinned certificate valid for more than 14 -- and exposes the SHA-256 for `serverCertificateHashes`
223
- (`{ selfSigned: { hosts, validityDays } }` to change the SANs or the lifetime). **It is not renewed:**
224
- restart before `notAfter`, and re-pin. A certificate you pass as `cert`/`privKey` is used as is
225
- (`certificateHash` is then `null`). `generateSelfSignedCertificate()` is exported for the same
226
- certificate without a server. The web platform's other pinning rules apply (`https:` URL, HTTP/3 only; see
227
- [Pinning a Self-Signed Certificate](#pinning-a-self-signed-certificate)).
223
+ (`{ selfSigned: { hosts, validityDays } }` to change the SANs or the lifetime). A certificate you pass as
224
+ `cert`/`privKey` is used as is (`certificateHash` is then `null`, and nothing rotates).
225
+ `generateSelfSignedCertificate()` is exported for the same certificate without a server. The web platform's other
226
+ pinning rules apply (`https:` URL, HTTP/3 only; see
227
+ [Pinning a Self-Signed Certificate](#pinning-a-self-signed-certificate)). **It is rotated, see below.**
228
+ - Node has no `WebTransport` global: a Node *client* needs `globalThis.WebTransport = (await import(
229
+ '@fails-components/webtransport')).WebTransport` first. UDP, so a firewall must allow `port`, and there
230
+ is no `wss://`-style TLS terminator in front: the certificate is the one this process serves.
231
+ - Tests (`test/server-webtransport.test.mjs`) run the stock client against it over a real HTTP/3
232
+ connection and are **skipped, with the reason, where the native binary cannot load**.
233
+
234
+ #### Certificate rotation
235
+
236
+ A pinned certificate cannot just be renewed in place: clients hold its hash, and the platform
237
+ takes none valid for more than 14 days. So a `selfSigned` certificate is rotated **ahead of its expiry, with an
238
+ overlap in which both hashes are published**:
239
+
240
+ ```js
241
+ server.certificateHashes(); // [{ algorithm: 'sha-256', value }, ...]: what a client should pin right now
242
+ await server.rotateCertificate(); // make the next certificate now and publish its hash too -> [current, next]
243
+ await server.rotateCertificate({ activate: true }); // ... and switch the listener to it immediately
244
+ ```
245
+
246
+ - **Automatically**: `prepareBeforeMs` before `notAfter` (default 3 days, at most a third of the validity) the next
247
+ certificate is generated and `certificateHashes()` becomes `[current, next]`; `activateBeforeMs` before `notAfter`
248
+ (default 1 hour, at most a sixth) the listener switches to it and the list drops the old hash. Then the next rotation
249
+ is scheduled from the new certificate. `selfSigned: { rotate: false }` turns this off (restart before `notAfter`,
250
+ as before); `{ prepareBeforeMs, activateBeforeMs, validityDays }` tune it (tests use a seconds-long validity).
251
+ The same list is on `server.webTransport().certificateHashes()`, with validity and which one is active in
252
+ `.certificates()`; `certificateHash`/`notAfter` there always describe the certificate being presented.
253
+ - **The switch restarts the HTTP/3 listener on the same port.** The native transport has no way to swap a certificate
254
+ in place and presents one certificate at a time, so live WebTransport sessions end at the switch (the WebSocket
255
+ listener is untouched) and clients reconnect. A client that pinned the overlap list `[current, next]` before the
256
+ switch connects after it; one that only has the old hash does not, so it must **re-pin**.
257
+ - **Client re-pin flow.** `serverCertificateHashes` takes several values, and `connect()` / `WebTransportTransport` pass the
258
+ whole array. Publish `server.certificateHashes()` over something the client already trusts (your page's
259
+ HTTPS, an API call, a config endpoint) and have the client fetch it (a) before connecting and (b) again whenever
260
+ the WebTransport handshake fails, then retry once:
261
+
262
+ ```js
263
+ // server: GET /wsh-pins -> hex strings (parseCertificateHash() on the client accepts hex)
264
+ app.get('/wsh-pins', (req, res) => res.json(server.certificateHashes().map((h) => Buffer.from(h.value).toString('hex'))));
265
+
266
+ // client
267
+ async function connectPinned(url, opts) {
268
+ for (let attempt = 0; ; attempt++) {
269
+ const pins = (await (await fetch('/wsh-pins')).json()).map((hex) => ({ algorithm: 'sha-256', value: hexToBytes(hex) }));
270
+ try { return await client.connect(url, { ...opts, transport: 'wt', webTransport: { serverCertificateHashes: pins } }); }
271
+ catch (err) { if (attempt === 1) throw err; } // stale pins: fetch the list again and retry once
272
+ }
273
+ }
274
+ ```
275
+
276
+ Because the next hash is published `prepareBeforeMs` (3 days) before the switch, a client that refreshes at least that
277
+ often (or on any failure) never sees a certificate it has not pinned. A client that stays up for longer than a certificate
278
+ lives and never refetches needs this retry; `transport: 'wt'` makes the failure explicit instead of falling back to
279
+ `wss://`.
280
+ - Not covered: a certificate you supply (`cert`/`privKey`) is yours to renew; restart the server (or run it behind a
281
+ CA-issued certificate, which needs no pinning).
282
+
228
283
  - Node has no `WebTransport` global: a Node *client* needs `globalThis.WebTransport = (await import(
229
284
  '@fails-components/webtransport')).WebTransport` first. UDP, so a firewall must allow `port`, and there
230
285
  is no `wss://`-style TLS terminator in front: the certificate is the one this process serves.
@@ -668,9 +723,9 @@ whoever supplies the `Server`/`Client`, and the package root stays
668
723
  dependency-free and browser-safe.
669
724
 
670
725
  **Compatibility.** Purely additive. Old clients never open `rpc` sessions; an old
671
- host (or the Rust `wsh-server`, which has no `rpc` kind yet) does not advertise
672
- `rpc`, so a new client fails fast with `UNSUPPORTED_PROTOCOL` instead of waiting
673
- on an `OpenFail`. Only `@johnhenry/wsh/server` serves `rpc` today. Not goals: a
726
+ host does not advertise `rpc`, so a new client fails fast with
727
+ `UNSUPPORTED_PROTOCOL` instead of waiting on an `OpenFail`. `@johnhenry/wsh/server`
728
+ and the Rust `wsh-server` (see below) serve `rpc` today. Not goals: a
674
729
  replacement for byte streams (exec/pty are unchanged), a new transport (`rpc`
675
730
  rides WebSocket/QMux and WebTransport streams), or an auth change -- an `rpc`
676
731
  session is opened on an authenticated connection and per-method authorization is
@@ -678,6 +733,33 @@ the handler's job (`-32003`; `ctx.user` / `ctx.fingerprint` identify the caller)
678
733
  Handlers run per channel, so register methods synchronously (or before the
679
734
  returned promise settles: inbound messages are held until it does).
680
735
 
736
+ **The Rust `wsh-server`** (since `rust-v0.4.0`) serves `wsh-host` and `wsh-fs`
737
+ exactly as the JS server does -- same features, methods, parameters, results,
738
+ error codes and messages, same CBOR-sequence decoder and size bound, same
739
+ `$/cancel` / `$/progress` -- over a WebSocket (QMux) connection. It does not
740
+ serve `mcp` (hosting an MCP server is an embedding concern) and does not offer
741
+ `rpc` on the native WebTransport listener, whose single control stream has no
742
+ client-opened data streams to carry a channel. `wsh-host` is on by default;
743
+ `wsh-fs` is **off until you give it a root** (file access is opt-in), and is
744
+ confined to it the way the JS `fs` option is. In `~/.wsh/config.toml`:
745
+
746
+ ```toml
747
+ [rpc]
748
+ enabled = true # default; false turns rpc off entirely
749
+ max_message_bytes = 1048576 # advertised as rpc-max-message:<n>; at least 256
750
+ max_inflight = 64 # concurrent requests per channel before -32002
751
+ fs_root = "/srv/files" # unset (the default): wsh-fs is not offered
752
+ fs_read_only = false
753
+ fs_max_file_bytes = 67108864
754
+ ```
755
+
756
+ Key options apply as they do to other channels: a key restricted to a forced
757
+ command opens no rpc channel, and `wsh-fs` needs the `file-transfer` scope
758
+ (`permit-file-transfer` under `restrict`). `host.info` reports the crate version
759
+ and `hostFingerprint: null` (the Rust server has no host key). The JS client
760
+ needs nothing new: it reads `rpc` from `ServerHello`, and its file helpers prefer
761
+ `wsh-fs` automatically once the server offers it.
762
+
681
763
  ## Attach and Resume
682
764
 
683
765
  Opening a PTY/exec session returns a session-scoped credential alongside the channel:
@@ -756,7 +838,10 @@ The constraints are the platform's, not wsh's:
756
838
 
757
839
  - The URL must be `https:`; pinning is HTTP/3 only, with no HTTP/2 fallback.
758
840
  - The certificate must use an **ECDSA P-256** key and be valid for **at most
759
- 14 days**, so it has to be reissued on a schedule.
841
+ 14 days**, so it has to be reissued on a schedule. `serverCertificateHashes`
842
+ takes several digests: pin the current and the next one across a rotation
843
+ (the Node host's `certificateHashes()` publishes both; see
844
+ [Certificate rotation](#certificate-rotation)).
760
845
  - Connection pooling is disabled for a pinned connection.
761
846
  - Only `sha-256` is accepted as the algorithm.
762
847
 
@@ -854,7 +939,7 @@ than left to be found.
854
939
  | Remove a remote file | `WshClient.fileRemove()` | `file_transfer::remove()`, `wsh sftp`'s `rm` | **Wire-unified**: `FileOp`/`FileResult` (`op: "remove"`) -- refused today by every `wsh-server` release ("not yet implemented"), the same refusal on both sides |
855
940
  | Install an authorized key | `WshClient.addAuthorizedKey()` | `wsh_client::WshClient::add_authorized_key()`, `wsh copy-id` | **Wire-unified** (wsh #59): `AuthorizedKeyAdd`/`AuthorizedKeyResult`, replacing a Rust-CLI-only shell command with a message every implementation can send |
856
941
  | Host identity / TOFU | `WshKnownHosts` (localStorage-backed) | `KnownHosts`/`HostStatus` (`~/.wsh/known_hosts`-backed) | **Record unified, policy is not** (wsh #59): both pin `ServerHello.host_fingerprint`, which `@johnhenry/wsh/server` populates (with a proof of possession, [Host key](#host-key-fingerprint--tofu)) but no Rust `wsh-server` release does yet (see [Security](#security-model)). *When* to trust, prompt, or persist is deliberately left per-implementation -- a browser and a CLI have different UX for "first time seeing this host" |
857
- | Typed RPC channels (`openRpc`, `rpc` sessions; `wsh-host`, `wsh-fs`, `mcp`) | `WshClient.openRpc()`, `RpcChannel` | none yet (a Rust `wsh-server` does not advertise `rpc`; a JS client falls back to `FileOp` for files) | **JS server only, no wire change**: `Open { kind: 'rpc', command: <protocol> }` plus `rpc*` `ServerHello` features, negotiated so it fails fast elsewhere |
942
+ | Typed RPC channels (`openRpc`, `rpc` sessions; `wsh-host`, `wsh-fs`, `mcp`) | `WshClient.openRpc()`, `RpcChannel` | Rust `wsh-server` (`rust-v0.4.0`+): `wsh-host`, and `wsh-fs` when `[rpc] fs_root` is set; no client yet in `wsh-client` | **No wire change**: `Open { kind: 'rpc', command: <protocol> }` plus `rpc*` `ServerHello` features, negotiated so it fails fast elsewhere; the Rust server serves it on WebSocket/QMux only |
858
943
  | Interactive shell UI | none -- this SDK is a protocol client, not a terminal emulator; pair with xterm.js/ghostty-web | `wsh connect`, `wsh sftp` (line-oriented REPL) | **Deliberately not unified** -- a browser embeds a terminal widget the host page owns; a CLI process owns its own TTY |
859
944
  | Attach / resume | `attachSession()`, `resumeSession()`, `WshSession.seq` | `attach_session()`, `resume_session()` | **Wire-unified** (`Attach`/`Resume`/`Presence`); `seq` = cumulative output bytes. `@johnhenry/wsh/server` honours `last_seq` (bounded ring, gap errors) and answers Presence with `channel_id`/`seq`; the Rust `wsh-server` does the same (`rust-v0.2.0`+; fixed 256 KiB ring) |
860
945
  | Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()`; `@johnhenry/wsh/server`: `relay` option and `createReverseHost()` | `wsh reverse`, `wsh agent` (persistent, with startup-unit install) | **Wire-unified** (registration, discovery, signed peer records); **daemonization is CLI-only** -- a browser tab cannot be a background OS service. The Node relay is default-deny and bridges one operator per peer (`busy:` reasons say so); E2E and bridged feature gates need `rust-v0.3.0`+ on the Rust side |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.26.0",
3
+ "version": "0.27.1",
4
4
  "description": "Web Shell \u2014 browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -215,10 +215,22 @@ export interface WshServerWebTransportOptions {
215
215
  privKey?: string;
216
216
  /**
217
217
  * Generate a short-lived ECDSA P-256 certificate (13 days; browsers refuse a pinned one valid for more than 14) and
218
- * expose its SHA-256 as `webTransport().certificateHash`, for the client's `serverCertificateHashes`. It is not
219
- * renewed: restart the server before `notAfter`. An object takes `{ hosts, validityDays }`.
218
+ * expose its SHA-256 as `webTransport().certificateHash`, for the client's `serverCertificateHashes`.
219
+ *
220
+ * It is rotated before it expires: `prepareBeforeMs` ahead of `notAfter` (default: 3 days, at most a third of the
221
+ * validity) the next certificate is generated and its hash published next to the current one
222
+ * (`server.certificateHashes()` returns both: a client that re-reads the list pins both), and `activateBeforeMs` ahead
223
+ * (default: 1 hour, at most a sixth of the validity) the listener switches to it. The switch restarts the HTTP/3
224
+ * listener on the same port, so live WebTransport sessions end and clients reconnect. `rotate: false` turns
225
+ * all of this off (restart before `notAfter`). An object takes `{ hosts, validityDays }` for the certificates too.
220
226
  */
221
- selfSigned?: boolean | { hosts?: string[]; validityDays?: number };
227
+ selfSigned?: boolean | {
228
+ hosts?: string[];
229
+ validityDays?: number;
230
+ rotate?: boolean;
231
+ prepareBeforeMs?: number;
232
+ activateBeforeMs?: number;
233
+ };
222
234
  /** QUIC stateless-reset secret (default: random). */
223
235
  secret?: string;
224
236
  }
@@ -234,6 +246,27 @@ export interface WshServerWebTransport {
234
246
  certificateHashHex: string | null;
235
247
  /** When the `selfSigned` certificate expires; `null` otherwise. */
236
248
  notAfter: Date | null;
249
+ /** The `serverCertificateHashes` to pin now: the certificate being presented and, during a rotation's overlap, the next. */
250
+ certificateHashes(): WshPinnedCertificateHash[];
251
+ /** The same certificates with their validity and which one is being presented. */
252
+ certificates(): WshServerCertificateInfo[];
253
+ /** See `WshServer.rotateCertificate()`. */
254
+ rotateCertificate(options?: { activate?: boolean }): Promise<{ current: WshServerCertificateInfo; next: WshServerCertificateInfo | null }>;
255
+ }
256
+
257
+ /** A `serverCertificateHashes` entry, ready to pass to `new WebTransport(url, { serverCertificateHashes })`. */
258
+ export interface WshPinnedCertificateHash {
259
+ algorithm: 'sha-256';
260
+ value: Uint8Array;
261
+ }
262
+
263
+ export interface WshServerCertificateInfo {
264
+ hash: Uint8Array;
265
+ hashHex: string;
266
+ notBefore: Date;
267
+ notAfter: Date;
268
+ /** Is it the one the listener presents right now? */
269
+ active: boolean;
237
270
  }
238
271
 
239
272
  export interface WshSelfSignedCertificate {
@@ -344,6 +377,19 @@ export interface WshServer {
344
377
  address(): WshServerAddress | null;
345
378
  /** The WebTransport listener; `null` when not configured or before `listen()` resolves. */
346
379
  webTransport(): WshServerWebTransport | null;
380
+ /**
381
+ * The `serverCertificateHashes` a WebTransport client should pin right now: the `selfSigned` certificate being
382
+ * presented and, during a rotation's overlap window, the next one. `[]` without a WebTransport listener or with a
383
+ * certificate you supplied. Serve it to your clients out of band (see the README's re-pin flow).
384
+ */
385
+ certificateHashes(): WshPinnedCertificateHash[];
386
+ /**
387
+ * Start rotating the `selfSigned` certificate: generate the next one and publish it in `certificateHashes()` now; the
388
+ * listener switches to it shortly before the current one expires (automatically, unless `selfSigned.rotate` is
389
+ * `false`). `{ activate: true }` switches immediately: live WebTransport sessions end. Rejects for a certificate
390
+ * you supplied, or before `listen()`.
391
+ */
392
+ rotateCertificate(options?: { activate?: boolean }): Promise<{ current: WshServerCertificateInfo; next: WshServerCertificateInfo | null }>;
347
393
  /** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
348
394
  peerFingerprints(): string[];
349
395
  /** The advertised host identity; `null` before `listen()` resolves or without a `hostKey`. */
@@ -36,7 +36,8 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
36
36
  * @param {{ port?: number, host?: string, path?: string, cert?: string, privKey?: string, selfSigned?: boolean | object, secret?: string }} [options.webTransport]
37
37
  * Also listen for WebTransport (HTTP/3 over UDP) clients, with the same auth and features as the WebSocket
38
38
  * listener. `cert` / `privKey` are PEM; `selfSigned: true` makes a 13-day ECDSA P-256 certificate and exposes its
39
- * SHA-256 through `webTransport()` for the client's `serverCertificateHashes`. Needs the optional peers
39
+ * SHA-256 through `webTransport()` / `certificateHashes()` for the client's `serverCertificateHashes`, and rotates it
40
+ * before it expires (`rotateCertificate()`). Needs the optional peers
40
41
  * `@fails-components/webtransport` and `@fails-components/webtransport-transport-http3-quiche`. Off unless given.
41
42
  * @param {{ canRegister?: Function, canConnect?: Function, maxPeers?: number, connectTimeoutMs?: number }} [options.relay]
42
43
  * Act as a relay: peers register (`ReverseRegister`, a signed record), operators list and connect to them
@@ -168,9 +169,29 @@ export function createWshServer({
168
169
  return {
169
170
  port: wt.port, host: wt.host, path: wt.path, url: `https://${wt.host.includes(':') ? `[${wt.host}]` : wt.host}:${wt.port}${wt.path}`,
170
171
  certificateHash: wt.certificateHash, certificateHashHex: wt.certificateHashHex, notAfter: wt.notAfter,
172
+ certificateHashes: () => wt.certificateHashes(), certificates: () => wt.certificates(), rotateCertificate: (o) => wt.rotateCertificate(o),
171
173
  };
172
174
  },
173
175
 
176
+ /**
177
+ * The `serverCertificateHashes` a WebTransport client should pin right now: the `selfSigned` certificate being
178
+ * presented and, during a rotation's overlap window, the next one too. `[]` with no WebTransport listener or
179
+ * with a certificate you supplied.
180
+ */
181
+ certificateHashes() {
182
+ return wt ? wt.certificateHashes() : [];
183
+ },
184
+
185
+ /**
186
+ * Start rotating the `selfSigned` WebTransport certificate: generate the next one and publish its hash in
187
+ * `certificateHashes()` now; it is switched to shortly before the current one expires (this happens by itself
188
+ * unless `selfSigned: { rotate: false }`). `{ activate: true }` switches immediately, ending live sessions.
189
+ */
190
+ rotateCertificate(options) {
191
+ if (!wt) return Promise.reject(new Error('rotateCertificate: there is no WebTransport listener (configure `webTransport` and `listen()`)'));
192
+ return wt.rotateCertificate(options);
193
+ },
194
+
174
195
  /** Fingerprints of the peers currently registered with this relay (`[]` when it is not one). */
175
196
  peerFingerprints() {
176
197
  return hub ? hub.peerFingerprints() : [];
@@ -82,9 +82,11 @@ export function generateSelfSignedCertificate({ hosts = ['localhost', '127.0.0.1
82
82
  }
83
83
  const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
84
84
  // An hour of back-dating tolerates clock skew; the whole window stays under 14 days for validityDays <= 13.
85
- const skewMs = Math.min(60 * 60 * 1000, (MAX_PINNED_CERT_DAYS - validityDays) * DAY_MS);
85
+ // (A very short validity, as rotation tests use, gets a proportionally smaller back-dating.)
86
+ const validityMs = validityDays * DAY_MS;
87
+ const skewMs = Math.min(60 * 60 * 1000, validityMs / 2, (MAX_PINNED_CERT_DAYS - validityDays) * DAY_MS);
86
88
  const notBefore = new Date(Math.floor((now.getTime() - skewMs) / 1000) * 1000);
87
- const notAfter = new Date(Math.floor((now.getTime() + validityDays * DAY_MS - skewMs) / 1000) * 1000);
89
+ const notAfter = new Date(Math.ceil((now.getTime() + validityMs - skewMs) / 1000) * 1000);
88
90
 
89
91
  const name = seq(set(seq(oid(OID.commonName), utf8('wsh self-signed'))));
90
92
  const san = seq(...hosts.map((h) => (net.isIP(h) ? der(0x87, ipBytes(h)) : der(0x82, Buffer.from(h, 'ascii')))));
@@ -13,6 +13,10 @@
13
13
  * host's `stream-announce` feature holds here as it does over QMux: the
14
14
  * client writes no primer.
15
15
  *
16
+ * A `selfSigned` certificate is rotated (see `startWebTransport`): the next one
17
+ * is generated and published (`certificateHashes()`) ahead of time, and the
18
+ * listener is switched to it shortly before the current one expires.
19
+ *
16
20
  * `@fails-components/webtransport` (and its native HTTP/3 transport package)
17
21
  * are optional peers, imported only when `webTransport` is given.
18
22
  */
@@ -22,6 +26,10 @@ import { FrameDecoder, frameEncode } from '../cbor.mjs';
22
26
  import { generateSelfSignedCertificate } from './self-signed.mjs';
23
27
 
24
28
  const DEFAULT_PATH = '/wsh';
29
+ const DAY_MS = 24 * 60 * 60 * 1000;
30
+ const HOUR_MS = 60 * 60 * 1000;
31
+ /** setTimeout cannot wait longer than this (a 32-bit signed millisecond count). */
32
+ const MAX_TIMER_MS = 2 ** 31 - 1;
25
33
 
26
34
  /** What a QMux stream looks like to the connection code, over a WebTransport bidirectional stream. */
27
35
  function adaptStream({ readable, writable }, onGone) {
@@ -59,19 +67,27 @@ function adaptStream({ readable, writable }, onGone) {
59
67
  * @param {string} opts.host
60
68
  * @param {(opts: object) => { receiveMessage: Function, bindStream: Function, close: Function }} opts.attach
61
69
  * @param {(line: string) => void} opts.log
62
- * @returns {Promise<{ port: number, host: string, path: string, certificateHash: Uint8Array | null, certificateHashHex: string | null, notAfter: Date | null, close(): Promise<void> }>}
70
+ * @returns {Promise<{ port: number, host: string, path: string, certificateHash: Uint8Array | null, certificateHashHex: string | null, notAfter: Date | null,
71
+ * certificateHashes(): object[], certificates(): object[], rotateCertificate(opts?: { activate?: boolean }): Promise<object>, close(): Promise<void> }>}
63
72
  */
64
73
  export async function startWebTransport({ options, host, attach, log }) {
65
74
  const { port = 0, path = DEFAULT_PATH, selfSigned = false } = options;
66
75
  let { cert, privKey } = options;
67
- let generated = null;
76
+ const certOpts = typeof selfSigned === 'object' ? selfSigned : {};
77
+ const { rotate = true, prepareBeforeMs: prepareOpt, activateBeforeMs: activateOpt, ...generateOpts } = certOpts;
78
+ /** The certificate being presented, and the next one once it has been generated (both only for `selfSigned`). */
79
+ let current = null;
80
+ let next = null;
68
81
  if (selfSigned) {
69
82
  if (cert || privKey) throw new TypeError('createWshServer: webTransport takes either selfSigned or cert/privKey, not both');
70
- generated = generateSelfSignedCertificate(typeof selfSigned === 'object' ? selfSigned : {});
71
- ({ cert, privKey } = generated);
83
+ current = generateSelfSignedCertificate(generateOpts);
84
+ ({ cert, privKey } = current);
72
85
  }
73
86
  if (!cert || !privKey) throw new TypeError('createWshServer: webTransport needs cert and privKey (PEM), or selfSigned: true');
74
87
  if (typeof path !== 'string' || !path.startsWith('/')) throw new TypeError('createWshServer: webTransport.path must start with "/"');
88
+ for (const [name, v] of [['prepareBeforeMs', prepareOpt], ['activateBeforeMs', activateOpt]]) {
89
+ if (v !== undefined && !(Number.isFinite(v) && v >= 0)) throw new TypeError(`createWshServer: webTransport.selfSigned.${name} must be a non-negative number`);
90
+ }
75
91
 
76
92
  let wtLib;
77
93
  try {
@@ -85,19 +101,7 @@ export async function startWebTransport({ options, host, attach, log }) {
85
101
  );
86
102
  }
87
103
 
88
- const server = new wtLib.Http3Server({
89
- port, host: options.host ?? host, secret: options.secret ?? randomBytes(16).toString('hex'), cert, privKey,
90
- });
91
- const sessions = server.sessionStream(path);
92
- server.startServer();
93
- try {
94
- await server.ready;
95
- } catch (err) {
96
- try { server.stopServer(); } catch { /* never started */ }
97
- throw err;
98
- }
99
- const addr = server.address();
100
-
104
+ const secret = options.secret ?? randomBytes(16).toString('hex');
101
105
  const live = new Set();
102
106
  let closing = false;
103
107
 
@@ -151,38 +155,154 @@ export async function startWebTransport({ options, host, attach, log }) {
151
155
  end();
152
156
  }
153
157
 
154
- (async () => {
155
- const reader = sessions.getReader();
158
+ /** Start an HTTP/3 listener presenting `pem` on `onPort` and feed its sessions to `serve`. */
159
+ async function launch(pem, onPort) {
160
+ const server = new wtLib.Http3Server({
161
+ port: onPort, host: options.host ?? host, secret, cert: pem.cert, privKey: pem.privKey,
162
+ });
163
+ const sessions = server.sessionStream(path);
164
+ server.startServer();
156
165
  try {
157
- for (;;) {
158
- const { done, value: session } = await reader.read();
159
- if (done || closing) break;
160
- serve(session).catch((e) => log(`webtransport session error: ${e.message}`));
166
+ await server.ready;
167
+ } catch (err) {
168
+ try { server.stopServer(); } catch { /* never started */ }
169
+ throw err;
170
+ }
171
+ (async () => {
172
+ const reader = sessions.getReader();
173
+ try {
174
+ for (;;) {
175
+ const { done, value: session } = await reader.read();
176
+ if (done || closing) break;
177
+ serve(session).catch((e) => log(`webtransport session error: ${e.message}`));
178
+ }
179
+ } catch { /* server stopped */ }
180
+ })();
181
+ return { server, addr: server.address() };
182
+ }
183
+
184
+ /** End every live session, giving the close a moment to reach the peers. */
185
+ async function closeLive() {
186
+ const open = [...live];
187
+ for (const s of open) { try { s.close(); } catch { /* gone */ } }
188
+ // Bounded: a peer that never acks cannot hold shutdown.
189
+ await Promise.race([
190
+ Promise.allSettled(open.map((s) => s.closed)),
191
+ new Promise((r) => { const t = setTimeout(r, 500); t.unref?.(); }),
192
+ ]);
193
+ live.clear();
194
+ }
195
+
196
+ let listener = await launch({ cert, privKey }, port);
197
+ const boundPort = listener.addr.port;
198
+
199
+ // ── Rotation (selfSigned only) ─────────────────────────────────────
200
+ //
201
+ // A pinned certificate cannot simply be renewed: clients pin its hash, and a
202
+ // browser takes no certificate valid for more than 14 days, so a long-running
203
+ // server would stop being reachable at `notAfter`. Instead the next
204
+ // certificate is generated AHEAD of time and its hash is published next to the
205
+ // current one (`certificateHashes()`), so a client that re-reads the list
206
+ // during the overlap window pins both and survives the switch. The listener
207
+ // presents one certificate at a time (the native transport cannot swap one in
208
+ // place, so the switch restarts it on the same port: sessions end, clients
209
+ // reconnect).
210
+ let prepareTimer = null;
211
+ let activateTimer = null;
212
+ let chain = Promise.resolve();
213
+ const serial = (fn) => { const run = chain.then(fn); chain = run.catch(() => {}); return run; };
214
+
215
+ const validityOf = (c) => c.notAfter.getTime() - c.notBefore.getTime();
216
+ const prepareBeforeMs = () => prepareOpt ?? Math.min(3 * DAY_MS, validityOf(current) / 3);
217
+ const activateBeforeMs = () => activateOpt ?? Math.min(HOUR_MS, validityOf(current) / 6);
218
+ const clearTimers = () => { clearTimeout(prepareTimer); clearTimeout(activateTimer); prepareTimer = activateTimer = null; };
219
+ const later = (fn, at) => {
220
+ // Long waits are re-armed: a timer cannot exceed ~24.8 days, and a sleeping machine fires late, so re-check on wake.
221
+ const wait = Math.max(0, at - Date.now());
222
+ const t = setTimeout(() => (wait > MAX_TIMER_MS ? later(fn, at) : fn()), Math.min(wait, MAX_TIMER_MS));
223
+ t.unref?.();
224
+ return t;
225
+ };
226
+
227
+ function schedule() {
228
+ clearTimers();
229
+ if (!current || !rotate || closing) return;
230
+ if (!next) {
231
+ prepareTimer = later(() => { rotateCertificate().catch((e) => log(`webtransport: preparing the next certificate failed: ${e.message}`)); }, current.notAfter.getTime() - prepareBeforeMs());
232
+ } else {
233
+ activateTimer = later(() => { rotateCertificate({ activate: true }).catch((e) => log(`webtransport: certificate rotation failed: ${e.message}`)); }, current.notAfter.getTime() - activateBeforeMs());
234
+ }
235
+ }
236
+
237
+ function prepare() {
238
+ if (next) return;
239
+ next = generateSelfSignedCertificate(generateOpts);
240
+ log(`webtransport: next certificate ready, sha-256 ${next.hashHex} (current ${current.hashHex} until ${current.notAfter.toISOString()})`);
241
+ }
242
+
243
+ async function activate() {
244
+ prepare();
245
+ const incoming = next;
246
+ await closeLive();
247
+ try { listener.server.stopServer(); } catch { /* already stopped */ }
248
+ // The UDP port may take a moment to come free.
249
+ let lastErr;
250
+ for (let attempt = 0; attempt < 20; attempt++) {
251
+ try {
252
+ listener = await launch(incoming, boundPort);
253
+ lastErr = null;
254
+ break;
255
+ } catch (err) {
256
+ lastErr = err;
257
+ await new Promise((r) => setTimeout(r, 100));
161
258
  }
162
- } catch { /* server stopped */ }
163
- })();
259
+ }
260
+ if (lastErr) throw new Error(`could not restart the WebTransport listener on port ${boundPort} with the new certificate: ${lastErr.message}`, { cause: lastErr });
261
+ const old = current;
262
+ current = incoming;
263
+ next = null;
264
+ log(`webtransport: now presenting certificate sha-256 ${current.hashHex} until ${current.notAfter.toISOString()} (replaced ${old.hashHex})`);
265
+ }
266
+
267
+ /**
268
+ * Start a rotation: generate the next certificate if there is none yet and publish its hash
269
+ * (`certificateHashes()` = [current, next]). With `activate`, also switch to it now (live sessions
270
+ * end); otherwise it is switched to shortly before the current one expires.
271
+ */
272
+ function rotateCertificate({ activate: now = false } = {}) {
273
+ if (!current) return Promise.reject(new Error('rotateCertificate: only a selfSigned certificate is rotated (supply a new cert by restarting the server)'));
274
+ return serial(async () => {
275
+ if (closing) throw new Error('rotateCertificate: the listener is closed');
276
+ if (now) await activate(); else prepare();
277
+ schedule();
278
+ return { current: describe(current, true), next: next ? describe(next, false) : null };
279
+ });
280
+ }
281
+
282
+ const describe = (c, active) => ({ hash: c.hash, hashHex: c.hashHex, notBefore: c.notBefore, notAfter: c.notAfter, active });
283
+ schedule();
164
284
 
165
285
  const bound = {
166
- port: addr.port,
167
- host: addr.host,
286
+ port: boundPort,
287
+ host: listener.addr.host,
168
288
  path,
169
- certificateHash: generated?.hash ?? null,
170
- certificateHashHex: generated?.hashHex ?? null,
171
- notAfter: generated?.notAfter ?? null,
289
+ get certificateHash() { return current?.hash ?? null; },
290
+ get certificateHashHex() { return current?.hashHex ?? null; },
291
+ get notAfter() { return current?.notAfter ?? null; },
292
+ /** `serverCertificateHashes` for a client: the current certificate and, during an overlap, the next. */
293
+ certificateHashes() { return [current, next].filter(Boolean).map((c) => ({ algorithm: 'sha-256', value: c.hash })); },
294
+ /** The same, with validity and which one is being presented. */
295
+ certificates() { return [current && describe(current, true), next && describe(next, false)].filter(Boolean); },
296
+ rotateCertificate,
172
297
  async close() {
173
298
  closing = true;
174
- const open = [...live];
175
- for (const s of open) { try { s.close(); } catch { /* gone */ } }
176
- // Let the close reach the peers before the socket is torn down (bounded: a peer that never acks cannot hold shutdown).
177
- await Promise.race([
178
- Promise.allSettled(open.map((s) => s.closed)),
179
- new Promise((r) => { const t = setTimeout(r, 500); t.unref?.(); }),
180
- ]);
181
- live.clear();
182
- try { server.stopServer(); } catch { /* already stopped */ }
299
+ clearTimers();
300
+ await chain.catch(() => {});
301
+ await closeLive();
302
+ try { listener.server.stopServer(); } catch { /* already stopped */ }
183
303
  },
184
304
  };
185
- log(`webtransport listening on https://${addr.host}:${addr.port}${path}`
186
- + (generated ? ` (self-signed, sha-256 ${generated.hashHex}, until ${generated.notAfter.toISOString()})` : ''));
305
+ log(`webtransport listening on https://${listener.addr.host}:${boundPort}${path}`
306
+ + (current ? ` (self-signed, sha-256 ${current.hashHex}, until ${current.notAfter.toISOString()}${rotate ? '' : ', not rotated'})` : ''));
187
307
  return bound;
188
308
  }