@johnhenry/wsh 0.27.1 → 0.29.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/src/index.d.ts CHANGED
@@ -616,6 +616,14 @@ export function reverseReject(opts?: {
616
616
  export function relayForward(opts?: {
617
617
  fromFingerprint?: string;
618
618
  inner?: Uint8Array;
619
+ /** Peer -> relay: the operator this is for, when the peer serves several (wsh #89). */
620
+ toFingerprint?: string;
621
+ }): WshMessage;
622
+
623
+ /** Relay -> peer: one operator of a multi-operator peer left (wsh #89). */
624
+ export function reverseClose(opts?: {
625
+ targetFingerprint?: string;
626
+ reason?: string;
619
627
  }): WshMessage;
620
628
 
621
629
  /** Message types a relay may forward inside a RelayForward wrapper. */
@@ -851,6 +859,8 @@ export function keyExchange(opts?: {
851
859
  sessionId?: string;
852
860
  kemPublicKey?: Uint8Array;
853
861
  kemCiphertext?: Uint8Array;
862
+ /** Round 1 only: the sender's Ed25519 signature over the exchange (see the spec's KeyExchange). */
863
+ signature?: Uint8Array;
854
864
  }): WshMessage;
855
865
 
856
866
  export function encryptedFrame(opts?: {
@@ -1909,6 +1919,27 @@ export interface WshPeerInfo {
1909
1919
  verified: boolean;
1910
1920
  }
1911
1921
 
1922
+ /** Host feature: it answers `KeyExchange` and seals/opens `EncryptedFrame`s (a reverse host with `e2e` on). */
1923
+ export const E2E_FEATURE: 'e2e';
1924
+ /** Host feature: it signs its `KeyExchange` reply, so `initiateE2E(..., { verifyPeer })` can authenticate it. */
1925
+ export const E2E_SIGN_FEATURE: 'e2e-sign';
1926
+
1927
+ /** The bytes a responder signs in `KeyExchange.signature`. */
1928
+ export function keyExchangeTranscript(t: {
1929
+ sessionId: string;
1930
+ /** The initiator's `algorithm` string. */
1931
+ algorithm: string;
1932
+ initiatorKey: Uint8Array;
1933
+ responderKey: Uint8Array;
1934
+ }): Uint8Array;
1935
+
1936
+ /** Verify a responder's `KeyExchange.signature` against its long-term Ed25519 public key. */
1937
+ export function verifyKeyExchangeSignature(
1938
+ peerIdentity: Uint8Array | CryptoKey,
1939
+ signature: Uint8Array | undefined,
1940
+ transcript: Parameters<typeof keyExchangeTranscript>[0],
1941
+ ): Promise<boolean>;
1942
+
1912
1943
  /** Result from WshClient.initiateE2E(). */
1913
1944
  export interface WshE2EResult {
1914
1945
  /** Non-extractable AES-256-GCM key derived from the exchange. */
@@ -1921,6 +1952,8 @@ export interface WshE2EResult {
1921
1952
  * requested but the peer didn't support it (automatic fallback).
1922
1953
  */
1923
1954
  hybrid: boolean;
1955
+ /** True when `verifyPeer` was given and the peer's signature over the exchange verified. */
1956
+ peerAuthenticated: boolean;
1924
1957
  }
1925
1958
 
1926
1959
  /**
@@ -2005,7 +2038,13 @@ export class WshClient {
2005
2038
  * In reverse mode, the relay bridge forwards messages from the CLI peer
2006
2039
  * to this browser client.
2007
2040
  */
2008
- onRelayMessage: ((msg: WshMessage) => void) | null;
2041
+ onRelayMessage: ((msg: WshMessage, fromFingerprint?: string) => void) | null;
2042
+
2043
+ /**
2044
+ * Called on a peer that serves several operators when the relay reports that one left (`ReverseClose`,
2045
+ * wsh #89): end that operator's bridge and clean up what belongs to it.
2046
+ */
2047
+ onReverseClose: ((msg: WshMessage) => void) | null;
2009
2048
 
2010
2049
  /**
2011
2050
  * Called when a gateway-subsystem control message arrives (opcodes 0x70-0x7f).
@@ -2169,7 +2208,7 @@ export class WshClient {
2169
2208
  /**
2170
2209
  * Send a control message over the authenticated relay connection.
2171
2210
  */
2172
- sendRelayControl(msg: WshMessage): Promise<void>;
2211
+ sendRelayControl(msg: WshMessage, opts?: { to?: string }): Promise<void>;
2173
2212
 
2174
2213
  /**
2175
2214
  * Send a raw control message on the authenticated connection.
@@ -2202,7 +2241,15 @@ export class WshClient {
2202
2241
  * fallback to classical if the peer doesn't support it). Experimental
2203
2242
  * -- the derived key is not yet wired to any actual encryption.
2204
2243
  */
2205
- initiateE2E(sessionId: string, algorithm?: 'X25519' | 'X25519+ML-KEM-768', timeout?: number): Promise<WshE2EResult>;
2244
+ initiateE2E(
2245
+ sessionId: string,
2246
+ algorithm?: 'X25519' | 'X25519+ML-KEM-768',
2247
+ timeout?: number,
2248
+ opts?: {
2249
+ /** The peer's long-term Ed25519 public key; the exchange is refused (`E2E_PEER_UNAUTHENTICATED`) unless it signed its half. */
2250
+ verifyPeer?: Uint8Array | CryptoKey;
2251
+ },
2252
+ ): Promise<WshE2EResult>;
2206
2253
 
2207
2254
  /**
2208
2255
  * Mark a peer fingerprint as an accepted reverse-connect bridge partner.
package/src/index.mjs CHANGED
@@ -17,7 +17,7 @@ export {
17
17
  presence, controlChanged, metrics,
18
18
  mcpDiscover, mcpTools, mcpCall, mcpResult,
19
19
  reverseRegister, reverseList, reversePeers, reverseConnect, reverseAccept, reverseReject,
20
- relayForward, RELAY_FORWARDABLE, isRelayForwardable,
20
+ relayForward, reverseClose, RELAY_FORWARDABLE, isRelayForwardable,
21
21
  openTcp, openUdp, resolveDns, gatewayOk, gatewayFail, gatewayClose,
22
22
  inboundOpen, inboundAccept, inboundReject, dnsResult,
23
23
  listenRequest, listenOk, listenFail, listenClose, gatewayData,
@@ -76,6 +76,9 @@ export { WshSession } from './session.mjs';
76
76
  export { WshVirtualSessionBackend, normalizeSessionData } from './virtual-session.mjs';
77
77
  export { WshClient, MCP_CALL_ID_FEATURE, STREAM_ANNOUNCE_FEATURE } from './client.mjs';
78
78
 
79
+ // End-to-end key exchange (responder side, feature names, signed transcript) -- wsh #90
80
+ export { E2E_FEATURE, E2E_SIGN_FEATURE, keyExchangeTranscript, verifyKeyExchangeSignature } from './e2e-exchange.mjs';
81
+
79
82
  // Key storage
80
83
  export { WshKeyStore } from './keystore.mjs';
81
84
 
@@ -63,6 +63,7 @@ export const MSG = Object.freeze({
63
63
  REVERSE_ACCEPT: 0x54,
64
64
  REVERSE_REJECT: 0x55,
65
65
  RELAY_FORWARD: 0x56,
66
+ REVERSE_CLOSE: 0x57,
66
67
 
67
68
  // Session
68
69
  SESSION_LIST: 0x5f,
@@ -510,12 +511,16 @@ export function reverseReject({ targetFingerprint, username, reason } = {}) {
510
511
  };
511
512
  }
512
513
 
513
- export function relayForward({ fromFingerprint, inner } = {}) {
514
- return {
515
- type: MSG.RELAY_FORWARD,
516
- from_fingerprint: fromFingerprint,
517
- inner,
518
- };
514
+ export function relayForward({ fromFingerprint, inner, toFingerprint } = {}) {
515
+ const msg = { type: MSG.RELAY_FORWARD, from_fingerprint: fromFingerprint, inner };
516
+ if (toFingerprint !== undefined) msg.to_fingerprint = toFingerprint;
517
+ return msg;
518
+ }
519
+
520
+ export function reverseClose({ targetFingerprint, reason } = {}) {
521
+ const msg = { type: MSG.REVERSE_CLOSE, target_fingerprint: targetFingerprint };
522
+ if (reason !== undefined) msg.reason = reason;
523
+ return msg;
519
524
  }
520
525
 
521
526
  export function sessionList({ sessions } = {}) {
@@ -762,11 +767,12 @@ export function copilotDetach({ sessionId, reason } = {}) {
762
767
  return msg;
763
768
  }
764
769
 
765
- export function keyExchange({ algorithm, publicKey, sessionId, kemPublicKey, kemCiphertext } = {}) {
770
+ export function keyExchange({ algorithm, publicKey, sessionId, kemPublicKey, kemCiphertext, signature } = {}) {
766
771
  const msg = { type: MSG.KEY_EXCHANGE, algorithm, session_id: sessionId };
767
772
  if (publicKey !== undefined) msg.public_key = publicKey;
768
773
  if (kemPublicKey !== undefined) msg.kem_public_key = kemPublicKey;
769
774
  if (kemCiphertext !== undefined) msg.kem_ciphertext = kemCiphertext;
775
+ if (signature !== undefined) msg.signature = signature;
770
776
  return msg;
771
777
  }
772
778
 
@@ -18,7 +18,7 @@ import { QMuxConnection } from '../qmux-connection.mjs';
18
18
  import { FrameDecoder, frameEncode } from '../cbor.mjs';
19
19
  import {
20
20
  MSG, serverHello, challenge, authOk, authFail, openOk, openFail, sessionData,
21
- exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk, mcpTools, mcpResult,
21
+ exit as exitMsg, close as closeMsg, encryptedFrame as encryptedFrameMsg, pong, fileResult, fileChunk, mcpTools, mcpResult,
22
22
  presence as presenceMsg, error as errorMsg, sessionList, detachOk, detachFail, reversePeers, reverseReject,
23
23
  } from '../messages.gen.mjs';
24
24
  import {
@@ -28,6 +28,9 @@ import { FILE_CHUNK_BYTES } from './fs.mjs';
28
28
  import { MAX_ATTACHMENTS } from './sessions.mjs';
29
29
  import { MCP_CALL_ID_FEATURE } from '../client.mjs';
30
30
  import { RpcChannel, RPC_FEATURE, RPC_MAX_MESSAGE_PREFIX, rpcProtocolFeature } from '../rpc.mjs';
31
+ import { sealFrame, openFrame, ROLE_TAGS } from '../e2e-frame.mjs';
32
+ import { E2EResponder, E2E_FEATURE, E2E_SIGN_FEATURE } from '../e2e-exchange.mjs';
33
+ import { MULTI_OPERATOR_FEATURE } from './relay.mjs';
31
34
  import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
32
35
 
33
36
  export const STREAM_ANNOUNCE = 'stream-announce';
@@ -42,6 +45,15 @@ export function hostFeatures(cfg, { dataStreams = true } = {}) {
42
45
  const features = dataStreams ? [STREAM_ANNOUNCE] : [];
43
46
  if (cfg.mcp) features.push(MCP_CALL_ID_FEATURE);
44
47
  if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
48
+ // End-to-end encryption (#90) is the virtual-mode EncryptedFrame layer, so it is offered where there are no
49
+ // client-opened streams: over a relay bridge.
50
+ // A relay carries a signed KeyExchange untouched, and (when configured) several operators per peer: peers read
51
+ // these from its ServerHello before relying on either, since an older relay would drop the connection on them.
52
+ if (cfg.relay) {
53
+ features.push(E2E_SIGN_FEATURE);
54
+ if (cfg.relay.maxOperatorsPerPeer > 1) features.push(MULTI_OPERATOR_FEATURE);
55
+ }
56
+ if (cfg.e2e && !dataStreams) features.push(E2E_FEATURE, ...(cfg.e2e.signKey ? [E2E_SIGN_FEATURE] : []));
45
57
  if (cfg.rpc && dataStreams && cfg.rpc.protocols.size) {
46
58
  features.push(RPC_FEATURE, ...[...cfg.rpc.protocols.keys()].map(rpcProtocolFeature), RPC_MAX_MESSAGE_PREFIX + cfg.rpc.maxMessageBytes);
47
59
  }
@@ -70,6 +82,8 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
70
82
  * @param {object | null} cfg.hostKey - from loadHostKey
71
83
  * @param {object | null} cfg.mcp - from createMcpHost
72
84
  * @param {{ protocols: Map<string, Function>, maxMessageBytes: number, maxInflight: number } | null} cfg.rpc - from createRpcHost
85
+ * @param {{ signKey: CryptoKey | null, hybrid?: boolean } | null} cfg.e2e - answer KeyExchange and seal virtual-mode traffic (bridged connections only)
86
+ * @param {Record<string, Function> | undefined} cfg.extensions - handlers for application-defined (string-typed) messages
73
87
  * @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
74
88
  * @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
75
89
  * @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
@@ -86,8 +100,9 @@ export function createConnectionFactory(cfg) {
86
100
  * for a transport that already frames messages itself (a relay bridge, WebTransport). `authenticated`
87
101
  * (`{ username, fingerprint }`) skips the handshake for a peer something else has already authenticated;
88
102
  * `dataStreams: false` means there are no client-opened streams to bind, so exec output rides SessionData.
103
+ * `e2e` overrides `cfg.e2e` for this connection.
89
104
  */
90
- return function attach({ send: sendBytes, sendMessage = null, remote = {}, authenticated = null, dataStreams = true, closeTransport = null }) {
105
+ return function attach({ send: sendBytes, sendMessage = null, remote = {}, authenticated = null, dataStreams = true, closeTransport = null, e2e: e2eOverride }) {
91
106
  const cid = ++counter;
92
107
  const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
93
108
  const state = {
@@ -111,7 +126,39 @@ export function createConnectionFactory(cfg) {
111
126
 
112
127
  const qmux = sendMessage ? null : new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
113
128
 
129
+ // ── End-to-end encryption (#90) ──────────────────────────────────
130
+ // An operator that sent KeyExchange for one of this connection's sessions gets that session's output sealed
131
+ // into EncryptedFrames and its input opened from them; plaintext input for it is ignored from then on (a relay
132
+ // must not be able to inject stdin around the encryption).
133
+ /** channel id -> the session id its OpenOk named (the AAD of its frames). */
134
+ const sessionOfChannel = new Map();
135
+ /** session id -> { key, sendCounter, recvCounter } for sessions with E2E on. */
136
+ const e2eSessions = new Map();
137
+ const MAX_E2E_SESSIONS = 64;
138
+ const e2eConfig = e2eOverride === undefined ? cfg.e2e : e2eOverride;
139
+ const responder = e2eConfig && !dataStreams ? new E2EResponder({ signKey: e2eConfig.signKey ?? null, hybrid: e2eConfig.hybrid !== false }) : null;
140
+ /** Outgoing messages in order, once E2E is on (sealing is async; order on the wire must not change). */
141
+ let outChain = Promise.resolve();
142
+ const e2eFor = (channelId) => e2eSessions.get(sessionOfChannel.get(channelId));
143
+
114
144
  const send = (msg) => {
145
+ if (state.closed) return Promise.resolve();
146
+ if (msg.type === MSG.OPEN_OK && typeof msg.session_id === 'string') sessionOfChannel.set(msg.channel_id, msg.session_id);
147
+ if (e2eSessions.size === 0) return deliver(msg);
148
+ // E2E is on for something: everything goes through one chain, so a sealed frame cannot be overtaken
149
+ // by (or overtake) the Exit that follows it.
150
+ const done = outChain.then(async () => {
151
+ const st = msg.type === MSG.SESSION_DATA ? e2eFor(msg.channel_id) : undefined;
152
+ if (!st) return deliver(msg);
153
+ const sessionId = sessionOfChannel.get(msg.channel_id);
154
+ const { nonce, ciphertext } = await sealFrame(st.key, sessionId, ROLE_TAGS.responder, st.sendCounter++, msg.data);
155
+ return deliver(encryptedFrameMsg({ channelId: msg.channel_id, nonce, ciphertext, sessionId }));
156
+ });
157
+ outChain = done.catch(() => {});
158
+ return done.catch(() => {});
159
+ };
160
+
161
+ const deliver = (msg) => {
115
162
  if (state.closed) return Promise.resolve();
116
163
  if (sendMessage) {
117
164
  try { return Promise.resolve(sendMessage(msg)).catch(() => {}); } catch { return Promise.resolve(); }
@@ -129,6 +176,8 @@ export function createConnectionFactory(cfg) {
129
176
  // everything else dies with it.
130
177
  for (const ch of [...channels.values()]) (ch.detach ?? ch.kill)?.call(ch);
131
178
  channels.clear();
179
+ responder?.close();
180
+ e2eSessions.clear();
132
181
  for (const c of mcpCalls) c.abort(new Error('connection closed'));
133
182
  mcpCalls.clear();
134
183
  for (const w of fileWrites.values()) clearTimeout(w.timer);
@@ -179,7 +228,12 @@ export function createConnectionFactory(cfg) {
179
228
  switch (m.type) {
180
229
  case MSG.PING: return send(pong({ id: m.id }));
181
230
  case MSG.OPEN: return handleOpen(m);
182
- case MSG.SESSION_DATA: channels.get(m.channel_id)?.input?.(m.data); return;
231
+ case MSG.SESSION_DATA:
232
+ if (e2eFor(m.channel_id)) { log(`ignored plaintext SessionData on E2E channel ${m.channel_id}`); return; }
233
+ channels.get(m.channel_id)?.input?.(m.data);
234
+ return;
235
+ case MSG.KEY_EXCHANGE: return handleKeyExchange(m);
236
+ case MSG.ENCRYPTED_FRAME: return handleEncryptedFrame(m);
183
237
  case MSG.RESIZE: channels.get(m.channel_id)?.resize?.(m.cols, m.rows); return;
184
238
  case MSG.SIGNAL: channels.get(m.channel_id)?.signal?.(String(m.signal ?? '')); return;
185
239
  case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
@@ -193,8 +247,44 @@ export function createConnectionFactory(cfg) {
193
247
  // Not awaited: the handler chain is serial, and a slow tool must not hold up
194
248
  // the next call (or a Close) behind it.
195
249
  case MSG.MCP_CALL: handleMcpCall(m); return;
196
- default: log(`ignored message type 0x${m.type.toString(16)}`);
250
+ default:
251
+ if (typeof m.type === 'string' && Object.hasOwn(cfg.extensions ?? {}, m.type) && typeof cfg.extensions[m.type] === 'function') {
252
+ try { await cfg.extensions[m.type](m, { username: state.username, fingerprint: state.fingerprint, send }); } catch (e) { log(`extension "${m.type}" threw: ${e.message}`); }
253
+ return;
254
+ }
255
+ log(`ignored message type ${typeof m.type === 'number' ? `0x${m.type.toString(16)}` : JSON.stringify(String(m.type)).slice(0, 80)}`);
256
+ }
257
+ }
258
+
259
+ async function handleKeyExchange(m) {
260
+ if (!responder) { log('ignored KeyExchange (end-to-end encryption is not enabled here)'); return; }
261
+ // Only for a session this connection actually has: the id is the AAD of every frame, and it bounds the state kept.
262
+ const known = typeof m.session_id === 'string' && [...sessionOfChannel.values()].includes(m.session_id);
263
+ if (!known) { log('ignored KeyExchange for a session this connection does not have'); return; }
264
+ if (!e2eSessions.has(m.session_id) && e2eSessions.size >= MAX_E2E_SESSIONS) { log('ignored KeyExchange (too many encrypted sessions)'); return; }
265
+ let result;
266
+ try { result = await responder.respond(m, send); } catch (e) { log(`KeyExchange refused: ${e.message}`); return; }
267
+ // Sealing starts after the reply is on its way, so the operator never gets a frame before it has the key.
268
+ if (result.key) {
269
+ e2eSessions.set(result.sessionId, { key: result.key, sendCounter: 0, recvCounter: 0 });
270
+ log(`end-to-end encryption on for session ${result.sessionId.slice(0, 8)}${result.hybrid ? ' (hybrid)' : ''}`);
271
+ }
272
+ }
273
+
274
+ async function handleEncryptedFrame(m) {
275
+ const st = e2eSessions.get(m.session_id);
276
+ // The frame must name the session its channel belongs to, or it could be spliced onto another channel.
277
+ if (!st || sessionOfChannel.get(m.channel_id) !== m.session_id) { log('dropped an EncryptedFrame for a session without E2E'); return; }
278
+ let plain;
279
+ try {
280
+ plain = await openFrame(st.key, m.session_id, st.recvCounter, { nonce: m.nonce, ciphertext: m.ciphertext, expectedRoleTag: ROLE_TAGS.initiator });
281
+ } catch (e) {
282
+ // Garbage (a replay, a tampered or foreign frame) neither reaches the process nor consumes a counter.
283
+ log(`dropped an EncryptedFrame: ${e.message}`);
284
+ return;
197
285
  }
286
+ st.recvCounter += 1;
287
+ channels.get(m.channel_id)?.input?.(plain);
198
288
  }
199
289
 
200
290
  async function refuse(reason) {
@@ -196,8 +196,25 @@ export interface WshServerRelayOptions {
196
196
  from: { username: string; fingerprint: string },
197
197
  to: WshRelayPeer,
198
198
  ) => boolean | Promise<boolean>;
199
+ /**
200
+ * Called (not awaited; a throw is logged) when an authenticated operator asks to connect to a peer that is not
201
+ * registered, with the operator, the fingerprint it asked for and the `ReverseConnect`'s claimed `username`. It
202
+ * never changes the answer (the operator is still told `no such peer`); it lets the host reach the peer another
203
+ * way, e.g. a Web Push notification.
204
+ */
205
+ onUnreachable?: (
206
+ from: { username: string; fingerprint: string },
207
+ targetFingerprint: string,
208
+ request: { username?: string },
209
+ ) => unknown;
199
210
  /** Registered peers at once (default 1024). */
200
211
  maxPeers?: number;
212
+ /**
213
+ * Operators bridged to one peer at once (default 1). Above 1, a peer is held to one operator unless it stated
214
+ * `relay-multi-operator` in its `ReverseAccept.features` (it then addresses replies with
215
+ * `RelayForward.to_fingerprint` and is sent a `ReverseClose` when one operator leaves instead of being closed).
216
+ */
217
+ maxOperatorsPerPeer?: number;
201
218
  /** How long a `ReverseConnect` waits for its peer to answer before the operator is rejected (default 8000). */
202
219
  connectTimeoutMs?: number;
203
220
  }
@@ -317,6 +334,16 @@ export interface WshServerOptions {
317
334
  * Default deny -- give both `canRegister` and `canConnect`. Off by default.
318
335
  */
319
336
  relay?: WshServerRelayOptions;
337
+ /**
338
+ * Serve the WebSocket listener over TLS (`wss://`) on the same port; these are the options of `https.createServer`.
339
+ * The WebTransport listener has its own certificate (`webTransport`). Off (plain `ws://`) by default.
340
+ */
341
+ tls?: { cert: string | Uint8Array; key: string | Uint8Array; ca?: string | Uint8Array; passphrase?: string };
342
+ /**
343
+ * Handlers for application-defined control messages: one whose `type` is a string (protocol messages are numeric, so
344
+ * there is no collision) from an authenticated connection is handed to `extensions[type]`. A throw is logged.
345
+ */
346
+ extensions?: Record<string, (msg: Record<string, unknown>, ctx: { username: string; fingerprint: string | null; send: (msg: object) => unknown }) => unknown>;
320
347
  /**
321
348
  * Also listen for WebTransport (HTTP/3 over UDP) clients -- the same auth, exec, pty, fs, mcp, sessions and relay as the
322
349
  * WebSocket listener, with real independent streams. Needs the optional peers `@fails-components/webtransport` and
@@ -421,6 +448,23 @@ export interface WshReverseHostOptions {
421
448
  /** Extra options for the relay connection (`expectHostKey`, `knownHosts`, `trustOnFirstUse`, ...). */
422
449
  connect?: Record<string, unknown>;
423
450
  peerType?: string;
451
+ /**
452
+ * Operators served at once (default 1). Above 1 the host states `relay-multi-operator`, addresses each reply
453
+ * to its operator and handles `ReverseClose`; each operator gets its own connection state and is passed through
454
+ * `accept` on its own identity. Needs `reportFeatures` and a relay with `maxOperatorsPerPeer` above 1; any other
455
+ * relay serves it one operator, as before.
456
+ */
457
+ maxOperators?: number;
458
+ /** State this host's features in `ReverseAccept.features` (default true). */
459
+ reportFeatures?: boolean;
460
+ /**
461
+ * End-to-end encryption for the bridged operator (default on; `false` turns it off). The host answers an
462
+ * operator's `KeyExchange` for a session it opened, seals that session's output into `EncryptedFrame`s and opens its
463
+ * input from them. `sign`: sign the reply with `keyPair` so the operator can authenticate this host
464
+ * (`initiateE2E(..., { verifyPeer })`); `'auto'` (default) signs when the relay's `ServerHello` carries `e2e-sign`.
465
+ * `hybrid` (default true) offers X25519+ML-KEM-768 when asked and available.
466
+ */
467
+ e2e?: false | { sign?: boolean | 'auto'; hybrid?: boolean };
424
468
  onLog?: (line: string) => void;
425
469
  }
426
470
 
@@ -433,6 +477,8 @@ export interface WshReverseHost {
433
477
  readonly fingerprint: string | null;
434
478
  /** Is the relay connection up right now? */
435
479
  readonly connected: boolean;
480
+ /** Fingerprints of the operators bridged right now. */
481
+ readonly operators: string[];
436
482
  }
437
483
 
438
484
  /**
@@ -39,10 +39,12 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
39
39
  * SHA-256 through `webTransport()` / `certificateHashes()` for the client's `serverCertificateHashes`, and rotates it
40
40
  * before it expires (`rotateCertificate()`). Needs the optional peers
41
41
  * `@fails-components/webtransport` and `@fails-components/webtransport-transport-http3-quiche`. Off unless given.
42
- * @param {{ canRegister?: Function, canConnect?: Function, maxPeers?: number, connectTimeoutMs?: number }} [options.relay]
42
+ * @param {{ canRegister?: Function, canConnect?: Function, maxPeers?: number, maxOperatorsPerPeer?: number, connectTimeoutMs?: number }} [options.relay]
43
43
  * Act as a relay: peers register (`ReverseRegister`, a signed record), operators list and connect to them
44
44
  * (`ReverseList` / `ReverseConnect`) and traffic is carried between them as `RelayForward`. Default deny:
45
45
  * `canRegister(who, record)` and `canConnect(from, to)` must both be given and return true. See `createReverseHost`.
46
+ * One operator per peer unless `maxOperatorsPerPeer` is raised -- and then only for peers that state
47
+ * `relay-multi-operator` (`createReverseHost({ maxOperators })`).
46
48
  * @param {true | { detachTtlMs?: number, maxDetached?: number, ringBytes?: number, sessionSecret?: string | Uint8Array }} [options.sessions]
47
49
  * Keep pty/exec sessions alive across disconnects so a client can `resumeSession()` / `attachSession()`
48
50
  * them: a per-server registry, a ring buffer of the newest `ringBytes` (default 1 MiB) of output, and
@@ -58,14 +60,22 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
58
60
  * identity, advertised (with proof of possession) so clients can pin it. See `src/host-key.mjs`.
59
61
  * @param {object} [options.auth.rateLimit] - Password-failure throttle: `{ maxFailures=5, windowMs=60000,
60
62
  * lockoutMs=60000, failureDelayMs=250, key(info) }` (`key` defaults to the peer address).
63
+ * @param {{ cert: string | Buffer, key: string | Buffer, ca?: string | Buffer, passphrase?: string }} [options.tls] - Serve the
64
+ * WebSocket listener over TLS (`wss://`) on the same port: the options of `https.createServer`. Off (plain `ws://`) unless given.
65
+ * The WebTransport listener has its own certificate (`webTransport.cert` / `selfSigned`).
66
+ * @param {Record<string, (msg: object, ctx: { username: string, fingerprint: string | null, send: Function }) => unknown>} [options.extensions]
67
+ * Handlers for application-defined messages: a control message whose `type` is a *string* (every protocol message
68
+ * has a numeric type, so these cannot collide) from an authenticated connection is passed to `extensions[type]`.
69
+ * A handler that throws is logged and ignored. Off unless given.
61
70
  * @param {number} [options.bindTimeoutMs=3000]
62
71
  * @param {(line: string) => void} [options.onLog]
63
72
  * @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
64
73
  */
65
74
  export function createWshServer({
66
- host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, webTransport, bindTimeoutMs = 3000, onLog = () => {},
75
+ host = '127.0.0.1', port = 0, tls, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, extensions, webTransport, bindTimeoutMs = 3000, onLog = () => {},
67
76
  } = {}) {
68
77
  let wss = null;
78
+ let httpsServer = null;
69
79
  let bound = null;
70
80
 
71
81
  const { execRunner, execOptions, pty: ptyConfig, files, mcp: mcpHost } = buildBackends({ exec, pty, fs, mcp });
@@ -76,6 +86,9 @@ export function createWshServer({
76
86
  key: typeof rl.key === 'function' ? rl.key : ({ address }) => String(address ?? 'unknown'),
77
87
  failureDelayMs: rl.failureDelayMs ?? 250,
78
88
  };
89
+ if (relay && relay.maxOperatorsPerPeer !== undefined && (!Number.isSafeInteger(relay.maxOperatorsPerPeer) || relay.maxOperatorsPerPeer < 1)) {
90
+ throw new TypeError('createWshServer: relay.maxOperatorsPerPeer must be a positive integer');
91
+ }
79
92
  const rpcHost = rpc ? createRpcHost(rpc, { files, maxMessageBytes: rpcMaxMessageBytes, maxInflight: rpcMaxInflight }) : null;
80
93
  let host_ = null;
81
94
  let registry = null;
@@ -105,12 +118,19 @@ export function createWshServer({
105
118
  });
106
119
  }
107
120
  if (relay) hub = new RelayHub({ ...relay, log: onLog });
121
+ if (extensions !== undefined && (extensions === null || typeof extensions !== 'object')) throw new TypeError('createWshServer: extensions must be an object of handlers');
108
122
  const attach = createConnectionFactory({
109
123
  authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
110
- methods, hostKey: host_, rateLimit, mcp: mcpHost, rpc: rpcHost, sessions: registry, relay: hub,
124
+ methods, hostKey: host_, rateLimit, extensions, mcp: mcpHost, rpc: rpcHost, sessions: registry, relay: hub,
111
125
  });
112
126
 
113
- wss = new WebSocketServer({ host, port });
127
+ if (tls) {
128
+ const { createServer } = await import('node:https');
129
+ httpsServer = createServer(tls);
130
+ wss = new WebSocketServer({ server: httpsServer });
131
+ } else {
132
+ wss = new WebSocketServer({ host, port });
133
+ }
114
134
  wss.on('connection', (ws, req) => {
115
135
  const conn = attach({
116
136
  send: (b) => { if (ws.readyState === 1) ws.send(b); },
@@ -125,10 +145,15 @@ export function createWshServer({
125
145
  ws.on('error', () => {});
126
146
  });
127
147
  await new Promise((resolve, reject) => {
128
- wss.once('listening', resolve);
129
- wss.once('error', reject);
148
+ if (httpsServer) {
149
+ httpsServer.once('error', reject);
150
+ httpsServer.listen(port, host, () => { httpsServer.off('error', reject); resolve(); });
151
+ } else {
152
+ wss.once('listening', resolve);
153
+ wss.once('error', reject);
154
+ }
130
155
  });
131
- const a = wss.address();
156
+ const a = (httpsServer ?? wss).address();
132
157
  bound = { address: a.address, port: a.port };
133
158
  if (webTransport) {
134
159
  try {
@@ -150,6 +175,7 @@ export function createWshServer({
150
175
  await wt?.close();
151
176
  wt = null;
152
177
  await new Promise((resolve) => server.close(resolve));
178
+ if (httpsServer) { const h = httpsServer; httpsServer = null; await new Promise((resolve) => h.close(resolve)); }
153
179
  registry?.closeAll();
154
180
  registry = null;
155
181
  hub?.closeAll();