@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/README.md CHANGED
@@ -63,7 +63,7 @@ Or via CDN:
63
63
  - **MCP bridge** -- discover and invoke remote MCP tools through the control channel
64
64
  - **Session recording** -- own JSON schema (not asciicast v2 -- see note below) recording and playback with seek/pause/resume
65
65
  - **Key management** -- IndexedDB storage with OPFS encrypted backup (PBKDF2 + AES-256-GCM)
66
- - **97 message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, authorized-key management, and more
66
+ - **98 message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, authorized-key management, and more
67
67
 
68
68
  > **Session recording is not asciicast v2.** Both `SessionRecorder`
69
69
  > implementations capture more than asciicast v2's three event codes
@@ -186,6 +186,14 @@ restricted host with no shell. `fs.root` confines every path: `..`, absolute
186
186
  paths and symlinks pointing out of it are refused -- for `write` and `rename`
187
187
  (both paths) exactly as for `read`.
188
188
 
189
+ ### TLS (`wss://`) and host extensions
190
+
191
+ `tls: { cert, key }` (PEM, the options of `https.createServer`) serves the WebSocket listener over TLS on the same
192
+ port. `extensions: { [type]: (msg, { username, fingerprint, send }) => void }` handles application-defined control
193
+ messages whose `type` is a string, from authenticated connections only. On a relay,
194
+ `relay: { onUnreachable(from, targetFingerprint, request) }` is told when an operator asked for a peer that is not
195
+ registered (the operator still gets `no such peer`), so the host can reach that peer another way, such as a Web Push.
196
+
189
197
  ### WebTransport listener
190
198
 
191
199
  The client's ladder prefers WebTransport (`https://`) and falls back to `wss://`;
@@ -342,23 +350,45 @@ const s = await client.openSession({ type: 'exec', command: 'echo hello' });
342
350
  checked against the allowlist (a non-forwardable or undecodable inner is
343
351
  dropped, logged), and re-wrapped with the real sender; traffic from a connection
344
352
  that is not bridged is never forwarded.
345
- - **One operator per peer at a time**, by design: a `RelayForward` names its
346
- sender but not its recipient, and nothing on the wire tells a peer that one
347
- of several operators left, so replies could not be addressed and a departed
348
- operator's work could not be cleaned up. A second `ReverseConnect` to a peer
349
- that already has an operator (or a request awaiting its answer) is rejected
350
- with `ReverseReject.reason` `busy: this peer already has an operator ...`
351
- (exported as `BUSY_PEER` from `src/server/relay.mjs`), and an operator that
352
- already has a bridge or a request in flight is rejected with `busy: you
353
- already have a bridge ...` (`BUSY_OPERATOR`); a request the peer does not
354
- answer within `connectTimeoutMs` (default 8 s) is `peer did not respond`.
355
- A bridge lasts as long as both connections: when either ends, the relay
356
- closes the other, so nothing an operator started outlives it (the reverse
357
- host kills its processes). `createReverseHost` redials with backoff by
358
- default (`reconnect: false` to turn it off), so it is registered again for
359
- the next operator. Lifting the limit needs a spec addition (a recipient on
360
- `RelayForward`, a bridge-ended notice) shared with the Rust side; it is
361
- tracked rather than built.
353
+ - **One operator per peer at a time, unless you raise it.** The default: a
354
+ second `ReverseConnect` to a peer that already has an operator (or a request
355
+ awaiting its answer) is rejected with `ReverseReject.reason` `busy: this peer
356
+ already has an operator ...` (exported as `BUSY_PEER` from
357
+ `src/server/relay.mjs`), and an operator that already has a bridge or a
358
+ request in flight is rejected with `busy: you already have a bridge ...`
359
+ (`BUSY_OPERATOR`); a request the peer does not answer within
360
+ `connectTimeoutMs` (default 8 s) is `peer did not respond`. A bridge lasts as
361
+ long as both connections: when either ends, the relay closes the other, so
362
+ nothing an operator started outlives it (the reverse host kills its
363
+ processes). `createReverseHost` redials with backoff by default
364
+ (`reconnect: false` to turn it off), so it is registered again for the next
365
+ operator.
366
+ - **Several operators per peer (opt-in on both ends).** Set
367
+ `relay: { maxOperatorsPerPeer: n }` on the relay and
368
+ `createReverseHost({ maxOperators: n })` on the peer. Three things make it
369
+ work, all optional on the wire so nothing older breaks: a peer states
370
+ `relay-multi-operator` in `ReverseAccept.features`; it addresses each message
371
+ to one operator with `RelayForward.to_fingerprint` (the relay routes it there
372
+ and re-wraps it with the peer as sender -- the field is never delivered, and
373
+ a forward addressed to nobody it serves is dropped, as is an unaddressed one
374
+ while two or more are bridged; with exactly one operator an unaddressed reply
375
+ still goes to it); and when one operator leaves the relay sends the peer a
376
+ `ReverseClose { target_fingerprint }` and keeps the peer registered, instead
377
+ of closing it. Each operator is accepted on its own identity (`accept` runs
378
+ per operator) and gets its own connection state on the host -- channels,
379
+ processes, files -- so when one leaves only its work is cleaned up. The relay
380
+ advertises `relay-multi-operator` in its `ServerHello` when it is configured
381
+ for several, and a host only claims it then, so a host with `maxOperators` on
382
+ an older or default relay serves one operator exactly as before. A peer that
383
+ never stated the feature stays held to one operator (and is closed when its
384
+ operator leaves) even on a multi-operator relay; a peer answers one
385
+ `ReverseConnect` at a time (a concurrent one is told `busy: ... retry in a
386
+ moment`), since a `ReverseAccept` does not name its operator; and at the cap
387
+ the reason is `busy: this peer already has its maximum of n operators`
388
+ (`busyPeerFull(n)`). The **Rust relay is still one operator per peer**: it
389
+ never sends `ReverseClose` and rejects the second operator; the new spec
390
+ fields are regenerated into the Rust types and ignored there. Tracked for the
391
+ Rust relay in #93.
362
392
  - **On the peer**, the bridged operator is served by the same connection code as
363
393
  a direct client, over an in-memory message pipe instead of a socket. Nothing a
364
394
  client opens can cross the relay but control messages, so exec sessions are
@@ -368,17 +398,39 @@ const s = await client.openSession({ type: 'exec', command: 'echo hello' });
368
398
  are on the spec's `forwardable` list, so an operator and a peer can run
369
399
  `initiateE2E()` / `enableE2E()` with each other through the relay: it carries
370
400
  the CBOR of each as opaque bytes and holds no key, so it sees only public
371
- values and ciphertext (`test/server-relay-bridge.test.mjs` checks the relay
372
- never holds the plaintext). **`KeyExchange` is unauthenticated**: this
373
- protects against a relay that only observes, not against one that substitutes
374
- the exchanged keys, so if the relay operator is not trusted, authenticate the
375
- derived key yourself (compare it out of band, or sign it with the keys the
376
- bridge already names). Only a stock-client peer (a browser tab, `connectReverse`)
377
- speaks E2E: **`createReverseHost` does not answer `KeyExchange` or open
378
- `EncryptedFrame`s** (the Node host has no E2E layer at all), so against it traffic
379
- is in the clear to the relay -- serve the relay over `wss://` and treat its
380
- operator as trusted. Key exchange from a connection that is not bridged is
381
- never carried.
401
+ values and ciphertext (`test/server-relay-bridge.test.mjs` and
402
+ `test/reverse-host-e2e.test.mjs` check the relay never holds the plaintext).
403
+ **`createReverseHost` answers it too** (`e2e` option, on by default; `false`
404
+ turns it off): for a session the host has opened (the id is the one in
405
+ `OpenOk`), an operator's `KeyExchange` is answered -- X25519, or hybrid
406
+ X25519+ML-KEM-768 when asked and available -- and from then on that session's
407
+ output is sealed into `EncryptedFrame`s (AAD = the session id, strict
408
+ counters, the `responder` role tag) and its input is opened from them.
409
+ Plaintext `SessionData` input for an encrypted session is ignored, so a relay
410
+ cannot inject stdin around the encryption, and a replayed, tampered or
411
+ spliced frame is dropped without consuming a counter. The host advertises
412
+ `e2e` (and `e2e-sign`) in `ReverseAccept.features`. Start E2E before the
413
+ session produces output you need confidential: output sent before the key
414
+ exists is plaintext, and a frame that reaches the operator before it has
415
+ called `enableE2E()` is dropped (open the session with a command that waits
416
+ for input, or use a shell and let the first prompt precede the exchange).
417
+ This is virtual-mode E2E (`EncryptedFrame`), so it is offered over a relay
418
+ bridge, not on direct connections (`createWshServer`).
419
+ **`KeyExchange` is unauthenticated by itself**, which protects against a
420
+ relay that only observes; to defeat one that substitutes keys the host
421
+ **signs its reply** with its identity key (Ed25519 over the label, session
422
+ id, algorithm and both ephemeral keys; the optional `KeyExchange.signature`),
423
+ and the operator checks it: `client.initiateE2E(sessionId, algorithm,
424
+ timeout, { verifyPeer: hostPublicKey })` -- the host's raw key, e.g. the
425
+ `public_key` of the record `listPeers()` verified -- rejects with
426
+ `code: 'E2E_PEER_UNAUTHENTICATED'` on a missing or wrong signature, and
427
+ resolves `peerAuthenticated: true` otherwise. Without `verifyPeer` nothing is
428
+ checked, as before. Signing is `e2e: { sign: 'auto' }` by default: the host
429
+ signs when the relay's `ServerHello` carries `e2e-sign` (this package's relay
430
+ does) because a relay built before the field existed -- notably Rust before
431
+ the release that regenerates it -- drops a connection that sends one;
432
+ `sign: true` forces it, `false` never. Key exchange from a connection that
433
+ is not bridged is never carried.
382
434
  - **Feature gates follow the host behind the bridge.** The host's own features
383
435
  (`file-write`, `file-rename`, `mcp-call-id`) are stated in the optional
384
436
  `ReverseAccept.features`; the relay forwards the list (bounded: at most 64
@@ -892,7 +944,7 @@ so options the platform gains later need no change here.
892
944
 
893
945
  | Export | Description |
894
946
  |--------|-------------|
895
- | `MSG` | 97 message type constants (hex opcodes) |
947
+ | `MSG` | 98 message type constants (hex opcodes) |
896
948
  | `CHANNEL_KIND` | Channel types: `pty`, `exec`, `meta`, `file`, `tcp`, `udp`, `job` |
897
949
  | `AUTH_METHOD` | Auth methods: `pubkey`, `password` |
898
950
  | `cborEncode` / `cborDecode` | CBOR codec (maps, arrays, strings, ints, bytes, bools, null, floats) |
@@ -942,7 +994,7 @@ than left to be found.
942
994
  | 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 |
943
995
  | 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 |
944
996
  | 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) |
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 |
997
+ | 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 unless `maxOperatorsPerPeer` is raised (`busy:` reasons say so); E2E and bridged feature gates need `rust-v0.3.0`+ on the Rust side; several operators per peer and the signed `KeyExchange` are JS-relay only for now |
946
998
  | Post-quantum E2E (experimental) | `initiateE2E()` (WebCrypto ML-KEM-768 or `@noble/post-quantum` fallback) | `E2eKeyExchange` (`ml-kem` crate) | **Wire-unified** algorithm and transcript; key material backends differ by platform necessity |
947
999
  | Session recording/replay | `SessionRecorder`/`SessionPlayer` (asciicast v2) | none | **JS-only** -- no current Rust consumer needs playback; the format itself (asciicast v2) is not proprietary if one is added later |
948
1000
  | Self-signed cert pinning | `serverCertificateHashes` (WebTransport option) | N/A -- Rust dials a cert its own TLS stack already trusts, or `--generate-cert`'s self-signed cert out of band | **Deliberately not unified** -- this is a browser-specific WebTransport API shape, not a wire-protocol concept |
@@ -1014,11 +1066,12 @@ out explicitly below rather than left to be discovered.
1014
1066
  `SHA-256("wsh-v1\0" || lp(username) || lp(session_id) || nonce || channel_binding)`,
1015
1067
  so a signature can't be replayed against a different session or relabeled
1016
1068
  to a different username.
1017
- - **E2E through a relay is opt-in and unauthenticated key agreement.** The relay
1018
- carries `KeyExchange` / `EncryptedFrame` between a bridged operator and peer
1019
- without being able to read the sealed frames, but `KeyExchange` carries no
1020
- signature: a relay that substitutes keys is not detected (see the [relay
1021
- section](#relay--reverse-mode)).
1069
+ - **E2E through a relay is opt-in.** The relay carries `KeyExchange` /
1070
+ `EncryptedFrame` between a bridged operator and peer without being able to
1071
+ read the sealed frames. `KeyExchange` is unauthenticated unless the peer signs
1072
+ it and the operator asks for that (`initiateE2E(..., { verifyPeer })`; a
1073
+ `createReverseHost` signs by default): without `verifyPeer` a relay that
1074
+ substitutes keys is not detected (see the [relay section](#relay--reverse-mode)).
1022
1075
  - **Signed peer records.** Reverse-mode registration is self-signed by the
1023
1076
  peer's identity key (the libp2p RFC 0002/0003 pattern), in a signing
1024
1077
  domain separate from the auth challenge. `listPeers()` verifies every
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.27.1",
3
+ "version": "0.29.0",
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",
package/src/client.mjs CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  hello, auth as authMsg, open as openMsg, close as closeMsg,
16
16
  attach as attachMsg, resume as resumeMsg, ping as pingMsg, pong as pongMsg,
17
17
  reverseRegister as reverseRegisterMsg, reverseList as reverseListMsg,
18
- reverseConnect as reverseConnectMsg,
18
+ reverseConnect as reverseConnectMsg, relayForward as relayForwardMsg,
19
19
  mcpDiscover as mcpDiscoverMsg, mcpCall as mcpCallMsg,
20
20
  suspendSession as suspendSessionMsg, restartPty as restartPtyMsg,
21
21
  metricsRequest as metricsRequestMsg,
@@ -39,8 +39,9 @@ import {
39
39
  } from './messages.mjs';
40
40
  import { signChallenge, exportPublicKeyRaw, signPeerRecord, verifyPeerRecord, importPublicKeyRaw, fingerprint as computeFingerprint } from './auth.mjs';
41
41
  import { generateMlKemKeyPair, mlKemEncapsulate, mlKemDecapsulate } from './mlkem.mjs';
42
+ import { compareBytes, combineHybridSecret, verifyKeyExchangeSignature } from './e2e-exchange.mjs';
42
43
  import { WshSession } from './session.mjs';
43
- import { cborDecode } from './cbor.mjs';
44
+ import { cborDecode, cborEncode } from './cbor.mjs';
44
45
  import {
45
46
  RpcChannel, RpcError, RPC_ERROR, RPC_FEATURE, RPC_PROTOCOL_NAME_RE, RPC_DEFAULT_MAX_MESSAGE,
46
47
  rpcProtocolFeature, parseRpcFeatures,
@@ -148,44 +149,6 @@ async function verifyPeerInfoRecord(peer) {
148
149
  }
149
150
  }
150
151
 
151
- /**
152
- * Lexicographic byte comparison, used by `initiateE2E`'s hybrid mode to
153
- * deterministically assign the ML-KEM "encapsulator" role without an
154
- * extra round trip: both sides already have both ephemeral X25519
155
- * public keys after round 1, so whichever side's own key sorts lower
156
- * encapsulates.
157
- * @returns {number} <0 if a<b, >0 if a>b, 0 if equal
158
- */
159
- function compareBytes(a, b) {
160
- const len = Math.min(a.length, b.length);
161
- for (let i = 0; i < len; i++) {
162
- if (a[i] !== b[i]) return a[i] - b[i];
163
- }
164
- return a.length - b.length;
165
- }
166
-
167
- /**
168
- * Combine the classical (X25519 ECDH) and post-quantum (ML-KEM-768) key
169
- * exchange outputs into one AES-256-GCM key via HKDF-SHA256, so the
170
- * final key is only as weak as the *stronger* of the two if either
171
- * primitive is ever broken.
172
- * @param {Uint8Array} x25519Bits - 32-byte ECDH shared secret
173
- * @param {Uint8Array} kemSharedSecret - 32-byte ML-KEM-768 shared secret
174
- * @returns {Promise<Uint8Array>} 32 bytes of combined key material
175
- */
176
- async function combineHybridSecret(x25519Bits, kemSharedSecret) {
177
- const ikm = new Uint8Array(x25519Bits.length + kemSharedSecret.length);
178
- ikm.set(x25519Bits, 0);
179
- ikm.set(kemSharedSecret, x25519Bits.length);
180
- const hkdfKey = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
181
- const bits = await crypto.subtle.deriveBits(
182
- { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode('wsh-hybrid-e2e-v1') },
183
- hkdfKey,
184
- 256
185
- );
186
- return new Uint8Array(bits);
187
- }
188
-
189
152
  /**
190
153
  * `expectHostKey` accepts a hex fingerprint, a raw 32-byte key, or an
191
154
  * `ssh-ed25519 AAAA...` line; all normalize to the hex fingerprint.
@@ -1259,10 +1222,19 @@ export class WshClient {
1259
1222
  * the transport internals directly.
1260
1223
  *
1261
1224
  * @param {object} msg
1225
+ * @param {{ to?: string }} [opts] - `to`: the operator this is for, when this peer serves several
1226
+ * (`relay-multi-operator`, wsh #89): the message is sent as a `RelayForward` addressed to that
1227
+ * fingerprint. Leave it out for a peer's only operator.
1262
1228
  * @returns {Promise<void>}
1263
1229
  */
1264
- async sendRelayControl(msg) {
1230
+ async sendRelayControl(msg, { to } = {}) {
1265
1231
  this.#assertAuthenticated('sendRelayControl');
1232
+ if (to !== undefined) {
1233
+ // A peer serving several operators addresses each message: the relay routes the wrapper to `to`
1234
+ // and re-wraps it with this peer's fingerprint (from_fingerprint here is overwritten, not trusted).
1235
+ if (typeof to !== 'string' || !to) throw new TypeError('sendRelayControl: to must be an operator fingerprint');
1236
+ msg = relayForwardMsg({ fromFingerprint: '', toFingerprint: to, inner: cborEncode(msg) });
1237
+ }
1266
1238
  await this.#transport.sendControl(msg);
1267
1239
  }
1268
1240
 
@@ -1784,12 +1756,19 @@ export class WshClient {
1784
1756
  * agility, not a hard cutover; check the returned `hybrid` flag to see
1785
1757
  * which actually happened.
1786
1758
  *
1759
+ * `KeyExchange` carries only ephemeral keys, so on its own it does not stop a relay that substitutes them.
1760
+ * Pass `verifyPeer` -- the peer's long-term Ed25519 public key, e.g. the `public_key` of the peer record you
1761
+ * listed -- to require that the peer signed its half (a reverse host does: `e2e-sign` in its features); the
1762
+ * exchange is refused with `code: 'E2E_PEER_UNAUTHENTICATED'` if the signature is missing or does not verify.
1763
+ *
1787
1764
  * @param {string} sessionId - Session ID
1788
1765
  * @param {string} [algorithm='X25519'] - 'X25519' or 'X25519+ML-KEM-768'
1789
1766
  * @param {number} [timeout=10000]
1790
- * @returns {Promise<{sharedSecret: CryptoKey, peerPublicKey: Uint8Array, hybrid: boolean}>}
1767
+ * @param {object} [opts]
1768
+ * @param {Uint8Array | CryptoKey} [opts.verifyPeer] - the peer's Ed25519 public key (raw 32 bytes or imported)
1769
+ * @returns {Promise<{sharedSecret: CryptoKey, peerPublicKey: Uint8Array, hybrid: boolean, peerAuthenticated: boolean}>}
1791
1770
  */
1792
- async initiateE2E(sessionId, algorithm = 'X25519', timeout = DEFAULT_OPEN_TIMEOUT) {
1771
+ async initiateE2E(sessionId, algorithm = 'X25519', timeout = DEFAULT_OPEN_TIMEOUT, { verifyPeer } = {}) {
1793
1772
  this.#assertAuthenticated('initiateE2E');
1794
1773
  const wantHybrid = algorithm === 'X25519+ML-KEM-768';
1795
1774
 
@@ -1835,6 +1814,22 @@ export class WshClient {
1835
1814
 
1836
1815
  const peerMsg = await peerMsgPromise;
1837
1816
 
1817
+ let peerAuthenticated = false;
1818
+ if (verifyPeer !== undefined) {
1819
+ const ok = await verifyKeyExchangeSignature(verifyPeer, peerMsg.signature, {
1820
+ sessionId, algorithm, initiatorKey: localPub, responderKey: new Uint8Array(peerMsg.public_key ?? []),
1821
+ });
1822
+ if (!ok) {
1823
+ throw Object.assign(
1824
+ new Error(peerMsg.signature === undefined
1825
+ ? 'initiateE2E: the peer did not sign its key exchange (verifyPeer was given)'
1826
+ : 'initiateE2E: the peer\'s key exchange signature does not verify against verifyPeer'),
1827
+ { code: 'E2E_PEER_UNAUTHENTICATED' },
1828
+ );
1829
+ }
1830
+ peerAuthenticated = true;
1831
+ }
1832
+
1838
1833
  // Round 2 has the same shape as round 1, so it needs the same treatment.
1839
1834
  // Whether we encapsulate or decapsulate is decided entirely by data
1840
1835
  // already in hand, so decide it now and register the ciphertext waiter
@@ -1899,7 +1894,7 @@ export class WshClient {
1899
1894
  ['encrypt', 'decrypt']
1900
1895
  );
1901
1896
 
1902
- return { sharedSecret, peerPublicKey: new Uint8Array(peerMsg.public_key), hybrid: hybridActive };
1897
+ return { sharedSecret, peerPublicKey: new Uint8Array(peerMsg.public_key), hybrid: hybridActive, peerAuthenticated };
1903
1898
  }
1904
1899
 
1905
1900
  // ── Structured File Channel ───────────────────────────────────────
@@ -2476,7 +2471,7 @@ export class WshClient {
2476
2471
  * @param {object} msg
2477
2472
  * @private
2478
2473
  */
2479
- #handleControl(msg) {
2474
+ #handleControl(msg, from) {
2480
2475
  const type = msg.type;
2481
2476
 
2482
2477
  // Unwrap RelayForward: only deliver the inner message if it came from a
@@ -2502,7 +2497,7 @@ export class WshClient {
2502
2497
  console.warn('[wsh:client] dropping RelayForward wrapping a non-forwardable type:', inner.type);
2503
2498
  return;
2504
2499
  }
2505
- this.#handleControl(inner);
2500
+ this.#handleControl(inner, msg.from_fingerprint);
2506
2501
  return;
2507
2502
  }
2508
2503
 
@@ -2668,7 +2663,7 @@ export class WshClient {
2668
2663
  // sessions share the same top-level API.
2669
2664
  if (this.onRelayMessage && this._isRelayForwardable(type)) {
2670
2665
  try {
2671
- this.onRelayMessage(msg);
2666
+ this.onRelayMessage(msg, from);
2672
2667
  } catch (err) {
2673
2668
  console.error('[wsh:client] onRelayMessage handler error:', err);
2674
2669
  }
@@ -2701,6 +2696,15 @@ export class WshClient {
2701
2696
  this.#transport?.sendControl(pingMsg({ id: ++this.#pingId })).catch(() => {});
2702
2697
  break;
2703
2698
 
2699
+ case MSG.REVERSE_CLOSE:
2700
+ // The relay telling a peer that serves several operators that one of them left (wsh #89).
2701
+ try {
2702
+ this.onReverseClose?.(msg);
2703
+ } catch (err) {
2704
+ console.error('[wsh:client] onReverseClose handler error:', err);
2705
+ }
2706
+ break;
2707
+
2704
2708
  case MSG.REVERSE_CONNECT:
2705
2709
  try {
2706
2710
  this.onReverseConnect?.(msg);
@@ -0,0 +1,227 @@
1
+ /**
2
+ * The key-exchange half of wsh's opt-in end-to-end encryption that is not the
3
+ * initiating client: the RESPONDER a host runs when an operator sends it a
4
+ * `KeyExchange` (wsh #90), plus the pieces both sides share -- the hybrid
5
+ * secret combiner, the byte comparison that picks the ML-KEM encapsulator, and
6
+ * the signed transcript that lets a host authenticate its half.
7
+ *
8
+ * `WshClient.initiateE2E()` is the initiator. The two sides derive the same
9
+ * AES-256-GCM key (see `e2e-frame.mjs` for how it is used to seal frames).
10
+ *
11
+ * ── Authenticating the responder ────────────────────────────────────
12
+ * `KeyExchange` carries only ephemeral public keys, so by itself it protects
13
+ * against a relay that observes, not one that substitutes keys. A responder
14
+ * given a signing key therefore signs, with its long-term Ed25519 identity, a
15
+ * transcript binding the label, the session id, the algorithm, and BOTH
16
+ * ephemeral X25519 keys (initiator's first). The initiator verifies it against
17
+ * the host key it meant to reach (`initiateE2E(..., { verifyPeer })`): a relay
18
+ * that swaps either ephemeral key cannot produce a matching signature.
19
+ */
20
+
21
+ import { sign as signBytes, verify as verifyBytes, importPublicKeyRaw } from './auth.mjs';
22
+ import { keyExchange } from './messages.gen.mjs';
23
+ import { generateMlKemKeyPair, mlKemEncapsulate, mlKemDecapsulate, MLKEM768_PUBLIC_KEY_LENGTH } from './mlkem.mjs';
24
+
25
+ export const E2E_ALGORITHM_CLASSICAL = 'X25519';
26
+ export const E2E_ALGORITHM_HYBRID = 'X25519+ML-KEM-768';
27
+ /** Host feature (in `ReverseAccept.features`): this host answers `KeyExchange` and seals/opens `EncryptedFrame`s. */
28
+ export const E2E_FEATURE = 'e2e';
29
+ /** Host feature: the host signs its `KeyExchange` reply, so the operator can authenticate it. */
30
+ export const E2E_SIGN_FEATURE = 'e2e-sign';
31
+
32
+ const LABEL = new TextEncoder().encode('wsh-e2e-keyexchange-v1\0');
33
+ const enc = new TextEncoder();
34
+ const MAX_PENDING = 8;
35
+ const PENDING_TTL_MS = 15_000;
36
+
37
+ /**
38
+ * Lexicographic byte comparison, used by hybrid mode to assign the ML-KEM
39
+ * "encapsulator" role without an extra round trip.
40
+ * @returns {number} <0 if a<b, >0 if a>b, 0 if equal
41
+ */
42
+ export function compareBytes(a, b) {
43
+ const len = Math.min(a.length, b.length);
44
+ for (let i = 0; i < len; i++) {
45
+ if (a[i] !== b[i]) return a[i] - b[i];
46
+ }
47
+ return a.length - b.length;
48
+ }
49
+
50
+ /**
51
+ * Combine the classical (X25519 ECDH) and post-quantum (ML-KEM-768) outputs
52
+ * into one AES-256-GCM key via HKDF-SHA256, so the final key is only as weak
53
+ * as the *stronger* of the two.
54
+ * @param {Uint8Array} x25519Bits - 32-byte ECDH shared secret
55
+ * @param {Uint8Array} kemSharedSecret - 32-byte ML-KEM-768 shared secret
56
+ * @returns {Promise<Uint8Array>} 32 bytes of combined key material
57
+ */
58
+ export async function combineHybridSecret(x25519Bits, kemSharedSecret) {
59
+ const ikm = new Uint8Array(x25519Bits.length + kemSharedSecret.length);
60
+ ikm.set(x25519Bits, 0);
61
+ ikm.set(kemSharedSecret, x25519Bits.length);
62
+ const hkdfKey = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
63
+ const bits = await crypto.subtle.deriveBits(
64
+ { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: enc.encode('wsh-hybrid-e2e-v1') },
65
+ hkdfKey,
66
+ 256
67
+ );
68
+ return new Uint8Array(bits);
69
+ }
70
+
71
+ const lp = (bytes) => {
72
+ const out = new Uint8Array(4 + bytes.length);
73
+ new DataView(out.buffer).setUint32(0, bytes.length);
74
+ out.set(bytes, 4);
75
+ return out;
76
+ };
77
+
78
+ /**
79
+ * The bytes a responder signs: label, session id, the INITIATOR's algorithm string, then the initiator's and
80
+ * responder's ephemeral X25519 public keys, each length-prefixed.
81
+ * @param {{ sessionId: string, algorithm: string, initiatorKey: Uint8Array, responderKey: Uint8Array }} t
82
+ * @returns {Uint8Array}
83
+ */
84
+ export function keyExchangeTranscript({ sessionId, algorithm, initiatorKey, responderKey }) {
85
+ const parts = [LABEL, lp(enc.encode(sessionId)), lp(enc.encode(algorithm)), lp(initiatorKey), lp(responderKey)];
86
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
87
+ let off = 0;
88
+ for (const p of parts) { out.set(p, off); off += p.length; }
89
+ return out;
90
+ }
91
+
92
+ /**
93
+ * Verify a responder's `KeyExchange.signature`.
94
+ * @param {Uint8Array | CryptoKey} peerIdentity - the host's raw 32-byte Ed25519 public key (or an imported one)
95
+ * @param {Uint8Array | undefined} signature
96
+ * @param {Parameters<typeof keyExchangeTranscript>[0]} transcript
97
+ * @returns {Promise<boolean>}
98
+ */
99
+ export async function verifyKeyExchangeSignature(peerIdentity, signature, transcript) {
100
+ if (!(signature instanceof Uint8Array) || signature.length !== 64) return false;
101
+ try {
102
+ const key = peerIdentity instanceof Uint8Array ? await importPublicKeyRaw(peerIdentity) : peerIdentity;
103
+ return await verifyBytes(key, signature, keyExchangeTranscript(transcript));
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * The responder side of `KeyExchange`, for a host. One instance per connection.
111
+ *
112
+ * `respond(msg)` takes each inbound `KeyExchange` and returns the messages to send back and, once the exchange is
113
+ * complete, the derived key:
114
+ * - a round-1 message (has `public_key`): generate an ephemeral key, answer, and -- classical, or hybrid with the
115
+ * host as encapsulator -- derive straight away; as decapsulator it waits for the round-2 ciphertext;
116
+ * - a round-2 message (only `kem_ciphertext`): finish a pending hybrid exchange.
117
+ */
118
+ export class E2EResponder {
119
+ #sign;
120
+ #hybrid;
121
+ /** @type {Map<string, { sharedBits: Uint8Array, localKem: { secretKeySeed: Uint8Array }, timer: any }>} */
122
+ #pending = new Map();
123
+
124
+ /**
125
+ * @param {object} [opts]
126
+ * @param {CryptoKey | null} [opts.signKey] - long-term Ed25519 private key; given, the reply is signed
127
+ * @param {boolean} [opts.hybrid=true] - offer X25519+ML-KEM-768 when the initiator asks for it
128
+ */
129
+ constructor({ signKey = null, hybrid = true } = {}) {
130
+ this.#sign = signKey;
131
+ this.#hybrid = hybrid;
132
+ }
133
+
134
+ get signs() { return !!this.#sign; }
135
+
136
+ /** Forget unfinished exchanges (the connection ended). */
137
+ close() {
138
+ for (const p of this.#pending.values()) clearTimeout(p.timer);
139
+ this.#pending.clear();
140
+ }
141
+
142
+ /**
143
+ * `emit(reply)` (optional) is called with each reply as soon as it is ready -- the round-1 reply BEFORE the key
144
+ * derivation and ML-KEM encapsulation, so the peer has it (and has registered for the ciphertext) well before the
145
+ * ciphertext follows. Without `emit` the replies are returned in `replies`.
146
+ *
147
+ * @param {object} msg - a KeyExchange
148
+ * @param {(reply: object) => Promise<void> | void} [emit]
149
+ * @returns {Promise<{ replies: object[], key?: CryptoKey, sessionId: string, hybrid?: boolean }>}
150
+ */
151
+ async respond(msg, emit) {
152
+ const sessionId = msg.session_id;
153
+ if (typeof sessionId !== 'string' || !sessionId) throw new Error('KeyExchange without a session_id');
154
+ if (msg.public_key === undefined && msg.kem_ciphertext !== undefined) return this.#finish(msg, sessionId);
155
+ return this.#begin(msg, sessionId, emit);
156
+ }
157
+
158
+ async #begin(msg, sessionId, emit) {
159
+ const replies = [];
160
+ const out = async (reply) => { if (emit) await emit(reply); else replies.push(reply); };
161
+ const algorithm = msg.algorithm;
162
+ if (algorithm !== E2E_ALGORITHM_CLASSICAL && algorithm !== E2E_ALGORITHM_HYBRID) throw new Error(`unsupported E2E algorithm ${JSON.stringify(algorithm)}`);
163
+ const peerPub = msg.public_key;
164
+ if (!(peerPub instanceof Uint8Array) || peerPub.length !== 32) throw new Error('KeyExchange public_key must be a 32-byte X25519 key');
165
+ const peerKem = msg.kem_public_key;
166
+ const wantHybrid = algorithm === E2E_ALGORITHM_HYBRID && this.#hybrid
167
+ && peerKem instanceof Uint8Array && peerKem.length === MLKEM768_PUBLIC_KEY_LENGTH;
168
+
169
+ // A fresh exchange for this session replaces an unfinished one.
170
+ this.#drop(sessionId);
171
+
172
+ const ephemeral = await crypto.subtle.generateKey({ name: 'X25519' }, false, ['deriveBits']);
173
+ const localPub = new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey));
174
+ let localKem = null;
175
+ if (wantHybrid) {
176
+ try { localKem = await generateMlKemKeyPair(); } catch { localKem = null; } // no ML-KEM backend here: fall back to classical
177
+ }
178
+ const hybrid = !!localKem;
179
+
180
+ const reply = {
181
+ algorithm: hybrid ? E2E_ALGORITHM_HYBRID : E2E_ALGORITHM_CLASSICAL,
182
+ publicKey: localPub,
183
+ sessionId,
184
+ kemPublicKey: localKem?.publicKey,
185
+ };
186
+ if (this.#sign) {
187
+ reply.signature = await signBytes(this.#sign, keyExchangeTranscript({ sessionId, algorithm, initiatorKey: peerPub, responderKey: localPub }));
188
+ }
189
+ await out(keyExchange(reply));
190
+
191
+ const peerKey = await crypto.subtle.importKey('raw', peerPub, { name: 'X25519' }, false, []);
192
+ const sharedBits = new Uint8Array(await crypto.subtle.deriveBits({ name: 'X25519', public: peerKey }, ephemeral.privateKey, 256));
193
+
194
+ if (!hybrid) return { replies, key: await toAesKey(sharedBits), sessionId, hybrid: false };
195
+
196
+ if (compareBytes(localPub, peerPub) < 0) {
197
+ // The lower key encapsulates against the other side's ML-KEM key.
198
+ const { ciphertext, sharedSecret } = await mlKemEncapsulate(peerKem);
199
+ await new Promise((resolve) => setTimeout(resolve, 0)); // let the round-1 reply land before the ciphertext follows
200
+ await out(keyExchange({ algorithm: E2E_ALGORITHM_HYBRID, sessionId, kemCiphertext: ciphertext }));
201
+ return { replies, key: await toAesKey(await combineHybridSecret(sharedBits, sharedSecret)), sessionId, hybrid: true };
202
+ }
203
+ // The higher key decapsulates: wait for the peer's ciphertext.
204
+ while (this.#pending.size >= MAX_PENDING) this.#drop(this.#pending.keys().next().value);
205
+ const timer = setTimeout(() => this.#drop(sessionId), PENDING_TTL_MS);
206
+ timer.unref?.();
207
+ this.#pending.set(sessionId, { sharedBits, localKem, timer });
208
+ return { replies, sessionId, hybrid: true };
209
+ }
210
+
211
+ async #finish(msg, sessionId) {
212
+ const pending = this.#pending.get(sessionId);
213
+ if (!pending) throw new Error('unexpected KeyExchange ciphertext (no hybrid exchange in progress for this session)');
214
+ this.#drop(sessionId);
215
+ const kemSecret = await mlKemDecapsulate(pending.localKem.secretKeySeed, new Uint8Array(msg.kem_ciphertext));
216
+ return { replies: [], key: await toAesKey(await combineHybridSecret(pending.sharedBits, kemSecret)), sessionId, hybrid: true };
217
+ }
218
+
219
+ #drop(sessionId) {
220
+ const p = this.#pending.get(sessionId);
221
+ if (p) { clearTimeout(p.timer); this.#pending.delete(sessionId); }
222
+ }
223
+ }
224
+
225
+ function toAesKey(bits) {
226
+ return crypto.subtle.importKey('raw', bits, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
227
+ }