@johnhenry/wsh 0.27.0 → 0.28.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,7 @@ 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)
73
86
  * @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
74
87
  * @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
75
88
  * @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
@@ -86,8 +99,9 @@ export function createConnectionFactory(cfg) {
86
99
  * for a transport that already frames messages itself (a relay bridge, WebTransport). `authenticated`
87
100
  * (`{ username, fingerprint }`) skips the handshake for a peer something else has already authenticated;
88
101
  * `dataStreams: false` means there are no client-opened streams to bind, so exec output rides SessionData.
102
+ * `e2e` overrides `cfg.e2e` for this connection.
89
103
  */
90
- return function attach({ send: sendBytes, sendMessage = null, remote = {}, authenticated = null, dataStreams = true, closeTransport = null }) {
104
+ return function attach({ send: sendBytes, sendMessage = null, remote = {}, authenticated = null, dataStreams = true, closeTransport = null, e2e: e2eOverride }) {
91
105
  const cid = ++counter;
92
106
  const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
93
107
  const state = {
@@ -111,7 +125,39 @@ export function createConnectionFactory(cfg) {
111
125
 
112
126
  const qmux = sendMessage ? null : new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
113
127
 
128
+ // ── End-to-end encryption (#90) ──────────────────────────────────
129
+ // An operator that sent KeyExchange for one of this connection's sessions gets that session's output sealed
130
+ // into EncryptedFrames and its input opened from them; plaintext input for it is ignored from then on (a relay
131
+ // must not be able to inject stdin around the encryption).
132
+ /** channel id -> the session id its OpenOk named (the AAD of its frames). */
133
+ const sessionOfChannel = new Map();
134
+ /** session id -> { key, sendCounter, recvCounter } for sessions with E2E on. */
135
+ const e2eSessions = new Map();
136
+ const MAX_E2E_SESSIONS = 64;
137
+ const e2eConfig = e2eOverride === undefined ? cfg.e2e : e2eOverride;
138
+ const responder = e2eConfig && !dataStreams ? new E2EResponder({ signKey: e2eConfig.signKey ?? null, hybrid: e2eConfig.hybrid !== false }) : null;
139
+ /** Outgoing messages in order, once E2E is on (sealing is async; order on the wire must not change). */
140
+ let outChain = Promise.resolve();
141
+ const e2eFor = (channelId) => e2eSessions.get(sessionOfChannel.get(channelId));
142
+
114
143
  const send = (msg) => {
144
+ if (state.closed) return Promise.resolve();
145
+ if (msg.type === MSG.OPEN_OK && typeof msg.session_id === 'string') sessionOfChannel.set(msg.channel_id, msg.session_id);
146
+ if (e2eSessions.size === 0) return deliver(msg);
147
+ // E2E is on for something: everything goes through one chain, so a sealed frame cannot be overtaken
148
+ // by (or overtake) the Exit that follows it.
149
+ const done = outChain.then(async () => {
150
+ const st = msg.type === MSG.SESSION_DATA ? e2eFor(msg.channel_id) : undefined;
151
+ if (!st) return deliver(msg);
152
+ const sessionId = sessionOfChannel.get(msg.channel_id);
153
+ const { nonce, ciphertext } = await sealFrame(st.key, sessionId, ROLE_TAGS.responder, st.sendCounter++, msg.data);
154
+ return deliver(encryptedFrameMsg({ channelId: msg.channel_id, nonce, ciphertext, sessionId }));
155
+ });
156
+ outChain = done.catch(() => {});
157
+ return done.catch(() => {});
158
+ };
159
+
160
+ const deliver = (msg) => {
115
161
  if (state.closed) return Promise.resolve();
116
162
  if (sendMessage) {
117
163
  try { return Promise.resolve(sendMessage(msg)).catch(() => {}); } catch { return Promise.resolve(); }
@@ -129,6 +175,8 @@ export function createConnectionFactory(cfg) {
129
175
  // everything else dies with it.
130
176
  for (const ch of [...channels.values()]) (ch.detach ?? ch.kill)?.call(ch);
131
177
  channels.clear();
178
+ responder?.close();
179
+ e2eSessions.clear();
132
180
  for (const c of mcpCalls) c.abort(new Error('connection closed'));
133
181
  mcpCalls.clear();
134
182
  for (const w of fileWrites.values()) clearTimeout(w.timer);
@@ -179,7 +227,12 @@ export function createConnectionFactory(cfg) {
179
227
  switch (m.type) {
180
228
  case MSG.PING: return send(pong({ id: m.id }));
181
229
  case MSG.OPEN: return handleOpen(m);
182
- case MSG.SESSION_DATA: channels.get(m.channel_id)?.input?.(m.data); return;
230
+ case MSG.SESSION_DATA:
231
+ if (e2eFor(m.channel_id)) { log(`ignored plaintext SessionData on E2E channel ${m.channel_id}`); return; }
232
+ channels.get(m.channel_id)?.input?.(m.data);
233
+ return;
234
+ case MSG.KEY_EXCHANGE: return handleKeyExchange(m);
235
+ case MSG.ENCRYPTED_FRAME: return handleEncryptedFrame(m);
183
236
  case MSG.RESIZE: channels.get(m.channel_id)?.resize?.(m.cols, m.rows); return;
184
237
  case MSG.SIGNAL: channels.get(m.channel_id)?.signal?.(String(m.signal ?? '')); return;
185
238
  case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
@@ -197,6 +250,37 @@ export function createConnectionFactory(cfg) {
197
250
  }
198
251
  }
199
252
 
253
+ async function handleKeyExchange(m) {
254
+ if (!responder) { log('ignored KeyExchange (end-to-end encryption is not enabled here)'); return; }
255
+ // Only for a session this connection actually has: the id is the AAD of every frame, and it bounds the state kept.
256
+ const known = typeof m.session_id === 'string' && [...sessionOfChannel.values()].includes(m.session_id);
257
+ if (!known) { log('ignored KeyExchange for a session this connection does not have'); return; }
258
+ if (!e2eSessions.has(m.session_id) && e2eSessions.size >= MAX_E2E_SESSIONS) { log('ignored KeyExchange (too many encrypted sessions)'); return; }
259
+ let result;
260
+ try { result = await responder.respond(m, send); } catch (e) { log(`KeyExchange refused: ${e.message}`); return; }
261
+ // Sealing starts after the reply is on its way, so the operator never gets a frame before it has the key.
262
+ if (result.key) {
263
+ e2eSessions.set(result.sessionId, { key: result.key, sendCounter: 0, recvCounter: 0 });
264
+ log(`end-to-end encryption on for session ${result.sessionId.slice(0, 8)}${result.hybrid ? ' (hybrid)' : ''}`);
265
+ }
266
+ }
267
+
268
+ async function handleEncryptedFrame(m) {
269
+ const st = e2eSessions.get(m.session_id);
270
+ // The frame must name the session its channel belongs to, or it could be spliced onto another channel.
271
+ if (!st || sessionOfChannel.get(m.channel_id) !== m.session_id) { log('dropped an EncryptedFrame for a session without E2E'); return; }
272
+ let plain;
273
+ try {
274
+ plain = await openFrame(st.key, m.session_id, st.recvCounter, { nonce: m.nonce, ciphertext: m.ciphertext, expectedRoleTag: ROLE_TAGS.initiator });
275
+ } catch (e) {
276
+ // Garbage (a replay, a tampered or foreign frame) neither reaches the process nor consumes a counter.
277
+ log(`dropped an EncryptedFrame: ${e.message}`);
278
+ return;
279
+ }
280
+ st.recvCounter += 1;
281
+ channels.get(m.channel_id)?.input?.(plain);
282
+ }
283
+
200
284
  async function refuse(reason) {
201
285
  log(`AUTH_FAIL: ${reason}`);
202
286
  await send(authFail({ reason }));
@@ -198,6 +198,12 @@ export interface WshServerRelayOptions {
198
198
  ) => boolean | Promise<boolean>;
199
199
  /** Registered peers at once (default 1024). */
200
200
  maxPeers?: number;
201
+ /**
202
+ * Operators bridged to one peer at once (default 1). Above 1, a peer is held to one operator unless it stated
203
+ * `relay-multi-operator` in its `ReverseAccept.features` (it then addresses replies with
204
+ * `RelayForward.to_fingerprint` and is sent a `ReverseClose` when one operator leaves instead of being closed).
205
+ */
206
+ maxOperatorsPerPeer?: number;
201
207
  /** How long a `ReverseConnect` waits for its peer to answer before the operator is rejected (default 8000). */
202
208
  connectTimeoutMs?: number;
203
209
  }
@@ -421,6 +427,23 @@ export interface WshReverseHostOptions {
421
427
  /** Extra options for the relay connection (`expectHostKey`, `knownHosts`, `trustOnFirstUse`, ...). */
422
428
  connect?: Record<string, unknown>;
423
429
  peerType?: string;
430
+ /**
431
+ * Operators served at once (default 1). Above 1 the host states `relay-multi-operator`, addresses each reply
432
+ * to its operator and handles `ReverseClose`; each operator gets its own connection state and is passed through
433
+ * `accept` on its own identity. Needs `reportFeatures` and a relay with `maxOperatorsPerPeer` above 1; any other
434
+ * relay serves it one operator, as before.
435
+ */
436
+ maxOperators?: number;
437
+ /** State this host's features in `ReverseAccept.features` (default true). */
438
+ reportFeatures?: boolean;
439
+ /**
440
+ * End-to-end encryption for the bridged operator (default on; `false` turns it off). The host answers an
441
+ * operator's `KeyExchange` for a session it opened, seals that session's output into `EncryptedFrame`s and opens its
442
+ * input from them. `sign`: sign the reply with `keyPair` so the operator can authenticate this host
443
+ * (`initiateE2E(..., { verifyPeer })`); `'auto'` (default) signs when the relay's `ServerHello` carries `e2e-sign`.
444
+ * `hybrid` (default true) offers X25519+ML-KEM-768 when asked and available.
445
+ */
446
+ e2e?: false | { sign?: boolean | 'auto'; hybrid?: boolean };
424
447
  onLog?: (line: string) => void;
425
448
  }
426
449
 
@@ -433,6 +456,8 @@ export interface WshReverseHost {
433
456
  readonly fingerprint: string | null;
434
457
  /** Is the relay connection up right now? */
435
458
  readonly connected: boolean;
459
+ /** Fingerprints of the operators bridged right now. */
460
+ readonly operators: string[];
436
461
  }
437
462
 
438
463
  /**
@@ -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
@@ -76,6 +78,9 @@ export function createWshServer({
76
78
  key: typeof rl.key === 'function' ? rl.key : ({ address }) => String(address ?? 'unknown'),
77
79
  failureDelayMs: rl.failureDelayMs ?? 250,
78
80
  };
81
+ if (relay && relay.maxOperatorsPerPeer !== undefined && (!Number.isSafeInteger(relay.maxOperatorsPerPeer) || relay.maxOperatorsPerPeer < 1)) {
82
+ throw new TypeError('createWshServer: relay.maxOperatorsPerPeer must be a positive integer');
83
+ }
79
84
  const rpcHost = rpc ? createRpcHost(rpc, { files, maxMessageBytes: rpcMaxMessageBytes, maxInflight: rpcMaxInflight }) : null;
80
85
  let host_ = null;
81
86
  let registry = null;
@@ -12,15 +12,22 @@
12
12
  * Nothing is open by default: `canRegister` and `canConnect` both default to
13
13
  * "no". `from_fingerprint` is always the sender's own authenticated key, set
14
14
  * here and never taken from a payload; only the spec's `forwardable` message
15
- * types cross a bridge. One bridge per peer at a time, and it lasts as long as
15
+ * types cross a bridge.
16
+ *
17
+ * By default there is one operator per peer, and the bridge lasts as long as
16
18
  * both connections: when either ends the hub closes the other, so no
17
19
  * half-bridged state (and no processes belonging to a departed operator)
18
- * outlives it.
20
+ * outlives it. With `maxOperatorsPerPeer` > 1 a peer that states
21
+ * `relay-multi-operator` in its ReverseAccept may be bridged to several
22
+ * operators at once (#89): it addresses each reply with
23
+ * `RelayForward.to_fingerprint`, and is told with a `ReverseClose` when one
24
+ * operator leaves (instead of being closed). A peer that never stated it is
25
+ * still held to one operator, and still closed when its operator leaves.
19
26
  */
20
27
 
21
28
  import { cborEncode, cborDecode } from '../cbor.mjs';
22
29
  import { fingerprint as fingerprintOf, importPublicKeyRaw, verifyPeerRecord } from '../auth.mjs';
23
- import { MSG, isRelayForwardable, relayForward, reversePeers, reverseReject } from '../messages.gen.mjs';
30
+ import { MSG, isRelayForwardable, relayForward, reversePeers, reverseReject, reverseClose } from '../messages.gen.mjs';
24
31
 
25
32
  export const DEFAULT_MAX_PEERS = 1024;
26
33
  export const DEFAULT_CONNECT_TIMEOUT_MS = 8000;
@@ -32,7 +39,16 @@ const MAX_FEATURE_LENGTH = 128;
32
39
  const SHORT_PREFIX_MIN = 8;
33
40
 
34
41
  /** `ReverseReject.reason` for a peer that already has an operator (or a request awaiting its answer). */
42
+ /** `ReverseAccept.features` entry by which a peer says it serves several operators (addresses replies, understands `ReverseClose`). */
43
+ export const MULTI_OPERATOR_FEATURE = 'relay-multi-operator';
44
+ /** `ReverseReject.reason` for a peer that is answering another operator's request right now. */
45
+ export const BUSY_ANSWERING = 'busy: this peer is answering another operator\'s request (retry in a moment)';
46
+
35
47
  export const BUSY_PEER = 'busy: this peer already has an operator (the relay bridges one operator per peer at a time; retry when it leaves)';
48
+ /** `ReverseReject.reason` for a multi-operator peer already at the relay's operator cap. */
49
+ export function busyPeerFull(max) {
50
+ return `busy: this peer already has its maximum of ${max} operators (retry when one leaves)`;
51
+ }
36
52
  /** `ReverseReject.reason` for an operator that already has a bridge, or a request in flight. */
37
53
  export const BUSY_OPERATOR = 'busy: you already have a bridge or a pending connect on this relay (one at a time)';
38
54
 
@@ -50,17 +66,23 @@ const isStr = (v, max) => typeof v === 'string' && v.length <= max;
50
66
  export class RelayHub {
51
67
  /** @type {Map<string, { handle: RelayHandle, meta: object }>} */
52
68
  #peers = new Map();
53
- /** @type {Map<RelayHandle, RelayHandle>} */
54
- #pairs = new Map();
69
+ /** operator -> the peer it is bridged to (an operator has one bridge). @type {Map<RelayHandle, RelayHandle>} */
70
+ #operatorPeer = new Map();
71
+ /** peer -> its bridged operators, by the operator's fingerprint. @type {Map<RelayHandle, Map<string, RelayHandle>>} */
72
+ #peerOperators = new Map();
73
+ /** peers that stated `relay-multi-operator` in their last ReverseAccept. @type {WeakSet<RelayHandle>} */
74
+ #multiPeers = new WeakSet();
55
75
  /** target handle -> { operator, timer } while a ReverseConnect awaits its answer */
56
76
  #pending = new Map();
57
77
  /** fingerprint -> the highest record seq ever accepted (survives disconnects, bounded). */
58
78
  #lastSeq = new Map();
59
79
 
60
- constructor({ canRegister, canConnect, maxPeers = DEFAULT_MAX_PEERS, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, log = () => {} } = {}) {
80
+ constructor({ canRegister, canConnect, maxPeers = DEFAULT_MAX_PEERS, maxOperatorsPerPeer = 1, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, log = () => {} } = {}) {
61
81
  for (const [name, fn] of [['canRegister', canRegister], ['canConnect', canConnect]]) {
62
82
  if (fn !== undefined && typeof fn !== 'function') throw new TypeError(`createWshServer: relay.${name} must be a function`);
63
83
  }
84
+ if (!Number.isSafeInteger(maxOperatorsPerPeer) || maxOperatorsPerPeer < 1) throw new TypeError('createWshServer: relay.maxOperatorsPerPeer must be a positive integer');
85
+ this.maxOperatorsPerPeer = maxOperatorsPerPeer;
64
86
  this.canRegister = canRegister ?? (() => false);
65
87
  this.canConnect = canConnect ?? (() => false);
66
88
  this.maxPeers = maxPeers;
@@ -193,14 +215,19 @@ export class RelayHub {
193
215
  fingerprint: target.meta.fingerprint, username: target.meta.username, capabilities: target.meta.capabilities,
194
216
  }))) return reject('no such peer');
195
217
  if (this.#peers.get(target.meta.fingerprint) !== target) return reject('no such peer');
196
- // One bridge per peer, and one per operator: a RelayForward names its sender but not its recipient, so a
197
- // peer's reply could not be addressed to one of several operators (see the README's relay section).
198
- if (this.#pairs.has(target.handle) || this.#pending.has(target.handle)) {
199
- return reject(BUSY_PEER);
200
- }
201
- if (this.#pairs.has(operator) || [...this.#pending.values()].some((p) => p.operator === operator)) {
218
+ // One bridge per operator. A peer serves one operator unless the relay allows more AND the peer said it can
219
+ // address them (RelayForward.to_fingerprint) and be told when one leaves (ReverseClose); it also answers one
220
+ // request at a time, since a ReverseAccept does not name the operator it answers.
221
+ if (this.bridged(operator) || [...this.#pending.values()].some((p) => p.operator === operator)) {
202
222
  return reject(BUSY_OPERATOR);
203
223
  }
224
+ if (this.#pending.has(target.handle)) return reject(this.maxOperatorsPerPeer > 1 ? BUSY_ANSWERING : BUSY_PEER);
225
+ const serving = this.#peerOperators.get(target.handle);
226
+ if (serving?.size) {
227
+ if (this.maxOperatorsPerPeer < 2 || !this.#multiPeers.has(target.handle)) return reject(BUSY_PEER);
228
+ if (serving.has(operator.fingerprint)) return reject(BUSY_OPERATOR);
229
+ if (serving.size >= this.maxOperatorsPerPeer) return reject(busyPeerFull(this.maxOperatorsPerPeer));
230
+ }
204
231
  const timer = setTimeout(() => {
205
232
  if (this.#pending.get(target.handle)?.operator !== operator) return;
206
233
  this.#pending.delete(target.handle);
@@ -232,26 +259,39 @@ export class RelayHub {
232
259
  }
233
260
  p.operator.send(answer);
234
261
  if (msg.type === MSG.REVERSE_ACCEPT) {
235
- this.#pairs.set(p.operator, peer);
236
- this.#pairs.set(peer, p.operator);
262
+ if (Array.isArray(answer.features) && answer.features.includes(MULTI_OPERATOR_FEATURE)) this.#multiPeers.add(peer);
263
+ else this.#multiPeers.delete(peer);
264
+ this.#operatorPeer.set(p.operator, peer);
265
+ let ops = this.#peerOperators.get(peer);
266
+ if (!ops) this.#peerOperators.set(peer, ops = new Map());
267
+ ops.set(p.operator.fingerprint, p.operator);
237
268
  this.log(`relay: bridged ${p.operator.fingerprint.slice(0, 12)} <-> ${peer.fingerprint.slice(0, 12)}`);
238
269
  }
239
270
  }
240
271
 
241
272
  /** Is `handle` one end of a bridge? */
242
273
  bridged(handle) {
243
- return this.#pairs.has(handle);
274
+ return this.#operatorPeer.has(handle) || (this.#peerOperators.get(handle)?.size ?? 0) > 0;
275
+ }
276
+
277
+ /** Fingerprints of the operators bridged to the peer with this fingerprint. */
278
+ operatorsOf(peerFingerprint) {
279
+ const peer = this.#peers.get(peerFingerprint)?.handle;
280
+ return peer ? [...(this.#peerOperators.get(peer)?.keys() ?? [])] : [];
244
281
  }
245
282
 
246
283
  /**
247
284
  * Carry a message across `handle`'s bridge. A plain forwardable message is
248
285
  * wrapped; a `RelayForward` the client wrote itself is unwrapped, its inner
249
286
  * checked, and re-wrapped -- either way `from_fingerprint` is the sender's
250
- * authenticated key. Returns false when there is no bridge.
287
+ * authenticated key. A peer serving several operators names the recipient
288
+ * with `to_fingerprint` (never set on what is delivered); with one operator
289
+ * it may leave it out. Returns false when there is no bridge.
251
290
  */
252
291
  forward(handle, msg) {
253
- const partner = this.#pairs.get(handle);
254
- if (!partner) return false;
292
+ const partner = this.#recipient(handle, msg);
293
+ if (partner === undefined) return false;
294
+ if (partner === null) return true; // bridged, but the message could not be addressed: dropped (and logged)
255
295
  let inner;
256
296
  if (msg.type === MSG.RELAY_FORWARD) {
257
297
  if (!(msg.inner instanceof Uint8Array) || msg.inner.byteLength > MAX_FORWARD_BYTES) { this.log('relay: dropped an oversized or malformed RelayForward'); return true; }
@@ -270,6 +310,27 @@ export class RelayHub {
270
310
  return true;
271
311
  }
272
312
 
313
+ /**
314
+ * Who `msg` from `handle` goes to: the partner handle; `undefined` if `handle` has no bridge (the caller
315
+ * treats the message as an ordinary one); `null` if it has one but the recipient cannot be determined.
316
+ */
317
+ #recipient(handle, msg) {
318
+ const peer = this.#operatorPeer.get(handle);
319
+ if (peer) return peer; // an operator has exactly one bridge
320
+ const operators = this.#peerOperators.get(handle);
321
+ if (!operators || operators.size === 0) return undefined;
322
+ const to = msg.type === MSG.RELAY_FORWARD ? msg.to_fingerprint : undefined;
323
+ if (to !== undefined) {
324
+ const operator = typeof to === 'string' ? operators.get(to) : undefined;
325
+ if (operator) return operator;
326
+ this.log(`relay: dropped a forward from ${handle.fingerprint.slice(0, 12)} addressed to ${typeof to === 'string' ? to.slice(0, 12) : '?'}, which is not one of its operators`);
327
+ return null;
328
+ }
329
+ if (operators.size === 1) return operators.values().next().value;
330
+ this.log(`relay: dropped an unaddressed forward from ${handle.fingerprint.slice(0, 12)}, which serves ${operators.size} operators (to_fingerprint is required)`);
331
+ return null;
332
+ }
333
+
273
334
  /** A connection ended: forget it, and end the bridge it was part of. */
274
335
  drop(handle) {
275
336
  const entry = this.#peers.get(handle.fingerprint);
@@ -279,11 +340,28 @@ export class RelayHub {
279
340
  for (const [target, pend] of this.#pending) {
280
341
  if (pend.operator === handle) { clearTimeout(pend.timer); this.#pending.delete(target); }
281
342
  }
282
- const partner = this.#pairs.get(handle);
283
- if (partner) {
284
- this.#pairs.delete(handle);
285
- this.#pairs.delete(partner);
286
- partner.close();
343
+ // An operator leaving.
344
+ const peer = this.#operatorPeer.get(handle);
345
+ if (peer) {
346
+ this.#operatorPeer.delete(handle);
347
+ const operators = this.#peerOperators.get(peer);
348
+ operators?.delete(handle.fingerprint);
349
+ if (this.maxOperatorsPerPeer > 1 && this.#multiPeers.has(peer)) {
350
+ // The peer serves others too (or may serve more): tell it which operator left instead of closing it.
351
+ peer.send(reverseClose({ targetFingerprint: handle.fingerprint, reason: 'operator left' }));
352
+ } else {
353
+ this.#peerOperators.delete(peer);
354
+ peer.close();
355
+ }
356
+ }
357
+ // A peer leaving: every operator bridged to it goes with it.
358
+ const operators = this.#peerOperators.get(handle);
359
+ if (operators) {
360
+ this.#peerOperators.delete(handle);
361
+ for (const operator of operators.values()) {
362
+ this.#operatorPeer.delete(operator);
363
+ operator.close();
364
+ }
287
365
  }
288
366
  }
289
367
 
@@ -291,7 +369,8 @@ export class RelayHub {
291
369
  closeAll() {
292
370
  for (const p of this.#pending.values()) clearTimeout(p.timer);
293
371
  this.#pending.clear();
294
- this.#pairs.clear();
372
+ this.#operatorPeer.clear();
373
+ this.#peerOperators.clear();
295
374
  this.#peers.clear();
296
375
  }
297
376
  }