@johnhenry/wsh 0.0.0 → 0.3.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
@@ -1,5 +1,7 @@
1
1
  # wsh-upon-star
2
2
 
3
+ Full documentation: [opensource.johnhenry.me/wsh](https://opensource.johnhenry.me/wsh/)
4
+
3
5
  > Previously published as `wsh-upon-star` (last release: 0.1.1, now deprecated).
4
6
  > Renamed to `@johnhenry/wsh` and restarted at 0.0.0 on import into the
5
7
  > @johnhenry family — a new name and era, not a maturity signal.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.0.0",
3
+ "version": "0.3.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",
@@ -14,7 +14,13 @@
14
14
  "README.md"
15
15
  ],
16
16
  "scripts": {
17
- "test": "node --test test/*.test.mjs"
17
+ "test": "node --test test/*.test.mjs",
18
+ "examples": "for f in examples/0*.mjs; do echo \"== $f\" && node \"$f\" || exit 1; done",
19
+ "example:01": "node examples/01-frames-survive-fragmented-transport.mjs",
20
+ "example:02": "node examples/02-tampered-challenge-fails-authentication.mjs",
21
+ "example:03": "node examples/03-handshake-to-exec-over-in-process-pipe.mjs",
22
+ "example:04": "node examples/04-recording-replays-with-original-timing.mjs",
23
+ "example:05": "node examples/05-remote-mcp-tools-called-through-bridge.mjs"
18
24
  },
19
25
  "keywords": [
20
26
  "wsh",
package/src/auth.mjs CHANGED
@@ -110,27 +110,62 @@ export async function verify(publicKey, signature, data) {
110
110
 
111
111
  // ── Authentication Transcript ─────────────────────────────────────────
112
112
 
113
+ /**
114
+ * Length-prefix a byte string with a 4-byte big-endian length, so two
115
+ * variable-length fields concatenated in sequence can't collide (e.g.
116
+ * username="ab", session="c" must hash differently from username="a",
117
+ * session="bc").
118
+ * @param {Uint8Array} bytes
119
+ * @returns {Uint8Array}
120
+ */
121
+ function lengthPrefixed(bytes) {
122
+ const out = new Uint8Array(4 + bytes.length);
123
+ new DataView(out.buffer).setUint32(0, bytes.length);
124
+ out.set(bytes, 4);
125
+ return out;
126
+ }
127
+
113
128
  /**
114
129
  * Build the authentication transcript hash for challenge-response signing.
115
130
  *
116
- * transcript = SHA-256("wsh-v1\0" || session_id || nonce || channel_binding)
131
+ * transcript = SHA-256(
132
+ * "wsh-v1\0" || lp(username) || lp(session_id) || nonce || channel_binding
133
+ * )
134
+ *
135
+ * `username` is bound so a signature produced for one username can't be
136
+ * replayed/relabeled as another. `session_id` and `username` are each
137
+ * length-prefixed (lp) since both are variable-length.
138
+ *
139
+ * `channelBinding` is reserved for a transport-layer identity binding (e.g.
140
+ * a TLS exporter value) — no caller currently populates it. Note this is
141
+ * NOT currently a binding to the *server's* identity: the protocol has no
142
+ * server host-identity concept today (SERVER_HELLO.fingerprints lists
143
+ * client keys the server authorizes, not the server's own key), and TLS
144
+ * exporter values aren't obtainable from a browser WebSocket/WebTransport
145
+ * client at all. See the wsh-modernization tracking issue for the
146
+ * follow-up covering server host-identity + TOFU pinning.
117
147
  *
118
148
  * @param {string} sessionId
119
149
  * @param {Uint8Array} nonce - 32-byte server nonce
120
- * @param {Uint8Array} [channelBinding] - Optional TLS channel binding
150
+ * @param {object} [opts]
151
+ * @param {string} [opts.username] - Username this transcript is bound to
152
+ * @param {Uint8Array} [opts.channelBinding] - Optional transport-layer binding
121
153
  * @returns {Promise<Uint8Array>} 32-byte SHA-256 hash
122
154
  */
123
- export async function buildTranscript(sessionId, nonce, channelBinding = new Uint8Array(0)) {
155
+ export async function buildTranscript(sessionId, nonce, { username = '', channelBinding = new Uint8Array(0) } = {}) {
124
156
  const enc = new TextEncoder();
125
157
  const versionBytes = enc.encode(PROTOCOL_VERSION + '\0');
126
- const sessionBytes = enc.encode(sessionId);
158
+ const usernameField = lengthPrefixed(enc.encode(username));
159
+ const sessionField = lengthPrefixed(enc.encode(sessionId));
127
160
 
128
- const total = versionBytes.length + sessionBytes.length + nonce.length + channelBinding.length;
161
+ const total = versionBytes.length + usernameField.length + sessionField.length
162
+ + nonce.length + channelBinding.length;
129
163
  const data = new Uint8Array(total);
130
164
  let offset = 0;
131
165
 
132
166
  data.set(versionBytes, offset); offset += versionBytes.length;
133
- data.set(sessionBytes, offset); offset += sessionBytes.length;
167
+ data.set(usernameField, offset); offset += usernameField.length;
168
+ data.set(sessionField, offset); offset += sessionField.length;
134
169
  data.set(nonce, offset); offset += nonce.length;
135
170
  data.set(channelBinding, offset);
136
171
 
@@ -148,11 +183,13 @@ export async function buildTranscript(sessionId, nonce, channelBinding = new Uin
148
183
  * @param {CryptoKey} publicKey
149
184
  * @param {string} sessionId
150
185
  * @param {Uint8Array} nonce
151
- * @param {Uint8Array} [channelBinding]
186
+ * @param {object} [opts]
187
+ * @param {string} [opts.username]
188
+ * @param {Uint8Array} [opts.channelBinding]
152
189
  * @returns {Promise<{ signature: Uint8Array, publicKeyRaw: Uint8Array }>}
153
190
  */
154
- export async function signChallenge(privateKey, publicKey, sessionId, nonce, channelBinding) {
155
- const transcript = await buildTranscript(sessionId, nonce, channelBinding);
191
+ export async function signChallenge(privateKey, publicKey, sessionId, nonce, opts) {
192
+ const transcript = await buildTranscript(sessionId, nonce, opts);
156
193
  const [signature, publicKeyRaw] = await Promise.all([
157
194
  sign(privateKey, transcript),
158
195
  exportPublicKeyRaw(publicKey),
@@ -167,11 +204,13 @@ export async function signChallenge(privateKey, publicKey, sessionId, nonce, cha
167
204
  * @param {Uint8Array} signature
168
205
  * @param {string} sessionId
169
206
  * @param {Uint8Array} nonce
170
- * @param {Uint8Array} [channelBinding]
207
+ * @param {object} [opts]
208
+ * @param {string} [opts.username]
209
+ * @param {Uint8Array} [opts.channelBinding]
171
210
  * @returns {Promise<boolean>}
172
211
  */
173
- export async function verifyChallenge(publicKey, signature, sessionId, nonce, channelBinding) {
174
- const transcript = await buildTranscript(sessionId, nonce, channelBinding);
212
+ export async function verifyChallenge(publicKey, signature, sessionId, nonce, opts) {
213
+ const transcript = await buildTranscript(sessionId, nonce, opts);
175
214
  return verify(publicKey, signature, transcript);
176
215
  }
177
216
 
package/src/cbor.mjs CHANGED
@@ -330,16 +330,57 @@ export function frameEncode(value) {
330
330
  return frame;
331
331
  }
332
332
 
333
+ /**
334
+ * Default cap on a single frame's claimed payload length (16 MiB).
335
+ *
336
+ * This channel only ever carries CBOR control-message metadata (session
337
+ * lists, MCP tool results, recording-export/command-journal entries,
338
+ * etc.) — bulk data such as file transfers and PTY output travel over
339
+ * separate raw data streams with their own small chunking, not through
340
+ * this decoder. 16 MiB is generous headroom for legitimate control
341
+ * payloads while still bounding memory far below the 4 GiB a hostile or
342
+ * buggy peer could otherwise claim via the 32-bit length prefix.
343
+ */
344
+ export const DEFAULT_MAX_FRAME_SIZE = 16 * 1024 * 1024;
345
+
346
+ /**
347
+ * Thrown by {@link FrameDecoder#feed} when a frame's claimed length
348
+ * exceeds the decoder's configured maximum.
349
+ */
350
+ export class FrameSizeError extends Error {
351
+ constructor(len, maxFrameSize) {
352
+ super(`Frame claims length ${len}, which exceeds the maximum of ${maxFrameSize} bytes`);
353
+ this.name = 'FrameSizeError';
354
+ this.len = len;
355
+ this.maxFrameSize = maxFrameSize;
356
+ }
357
+ }
358
+
333
359
  /**
334
360
  * Streaming frame decoder. Feed it chunks and it yields complete messages.
335
361
  */
336
362
  export class FrameDecoder {
337
363
  #buffer = new Uint8Array(0);
364
+ #maxFrameSize;
365
+
366
+ /**
367
+ * @param {object} [opts]
368
+ * @param {number} [opts.maxFrameSize] - Maximum allowed claimed frame
369
+ * length, in bytes. Frames claiming more than this are rejected as
370
+ * soon as their length prefix is parsed, before any payload bytes
371
+ * are buffered. Defaults to {@link DEFAULT_MAX_FRAME_SIZE}.
372
+ */
373
+ constructor({ maxFrameSize = DEFAULT_MAX_FRAME_SIZE } = {}) {
374
+ this.#maxFrameSize = maxFrameSize;
375
+ }
338
376
 
339
377
  /**
340
378
  * Feed bytes and return decoded messages.
341
379
  * @param {Uint8Array} chunk
342
380
  * @returns {Array} decoded JS values
381
+ * @throws {FrameSizeError} if a frame's claimed length exceeds the
382
+ * configured maximum. The decoder's buffer is cleared before
383
+ * throwing; callers should treat this as fatal for the connection.
343
384
  */
344
385
  feed(chunk) {
345
386
  this.#buffer = appendBuffer(this.#buffer, chunk);
@@ -347,6 +388,12 @@ export class FrameDecoder {
347
388
  while (this.#buffer.length >= 4) {
348
389
  const view = new DataView(this.#buffer.buffer, this.#buffer.byteOffset, 4);
349
390
  const len = view.getUint32(0);
391
+ if (len > this.#maxFrameSize) {
392
+ // Reject before accumulating toward the (possibly huge) claimed
393
+ // length — do not wait for the payload to arrive.
394
+ this.#buffer = new Uint8Array(0);
395
+ throw new FrameSizeError(len, this.#maxFrameSize);
396
+ }
350
397
  if (this.#buffer.length < 4 + len) break;
351
398
  const payload = this.#buffer.slice(4, 4 + len);
352
399
  this.#buffer = this.#buffer.slice(4 + len);
package/src/client.mjs CHANGED
@@ -362,7 +362,8 @@ export class WshClient {
362
362
  keyPair.privateKey,
363
363
  keyPair.publicKey,
364
364
  tempSessionId,
365
- challengeMsg.nonce
365
+ challengeMsg.nonce,
366
+ { username }
366
367
  );
367
368
 
368
369
  await transport.sendControl(
@@ -388,14 +389,18 @@ export class WshClient {
388
389
  }
389
390
 
390
391
  // We need a session ID for the transcript. Use the nonce-derived ID
391
- // or a placeholder if the server hasn't provided one.
392
- tempSessionId = tempSessionId || 'pending';
392
+ // or a fresh random placeholder if the server hasn't provided one.
393
+ // (A fixed literal here would make the transcript's session-id
394
+ // component identical across every connection taking this path,
395
+ // leaving only the nonce to bind the signature to this connection.)
396
+ tempSessionId = tempSessionId || crypto.randomUUID();
393
397
 
394
398
  const { signature, publicKeyRaw } = await signChallenge(
395
399
  keyPair.privateKey,
396
400
  keyPair.publicKey,
397
401
  tempSessionId,
398
- firstResponse.nonce
402
+ firstResponse.nonce,
403
+ { username }
399
404
  );
400
405
 
401
406
  await transport.sendControl(
@@ -1431,7 +1436,7 @@ export class WshClient {
1431
1436
  }
1432
1437
 
1433
1438
  const { signature, publicKeyRaw } = await signChallenge(
1434
- keyPair.privateKey, keyPair.publicKey, tempSessionId, challengeMsg.nonce
1439
+ keyPair.privateKey, keyPair.publicKey, tempSessionId, challengeMsg.nonce, { username }
1435
1440
  );
1436
1441
 
1437
1442
  await this.#transport.sendControl(authMsg({
@@ -1445,9 +1450,11 @@ export class WshClient {
1445
1450
  } else if (firstResponse.type === MSG.CHALLENGE) {
1446
1451
  if (!keyPair) throw new Error('Server sent CHALLENGE but no key pair provided');
1447
1452
 
1448
- tempSessionId = tempSessionId || 'pending';
1453
+ // See connect()'s CHALLENGE branch for why this must be random
1454
+ // rather than a fixed literal.
1455
+ tempSessionId = tempSessionId || crypto.randomUUID();
1449
1456
  const { signature, publicKeyRaw } = await signChallenge(
1450
- keyPair.privateKey, keyPair.publicKey, tempSessionId, firstResponse.nonce
1457
+ keyPair.privateKey, keyPair.publicKey, tempSessionId, firstResponse.nonce, { username }
1451
1458
  );
1452
1459
 
1453
1460
  await this.#transport.sendControl(authMsg({
package/src/index.d.ts CHANGED
@@ -27,12 +27,35 @@ export function cborDecode(data: Uint8Array): unknown;
27
27
  */
28
28
  export function frameEncode(value: unknown): Uint8Array;
29
29
 
30
+ /**
31
+ * Default cap on a single frame's claimed payload length (16 MiB).
32
+ */
33
+ export const DEFAULT_MAX_FRAME_SIZE: number;
34
+
35
+ /**
36
+ * Thrown by FrameDecoder#feed when a frame's claimed length exceeds the
37
+ * decoder's configured maximum.
38
+ */
39
+ export class FrameSizeError extends Error {
40
+ constructor(len: number, maxFrameSize: number);
41
+ readonly len: number;
42
+ readonly maxFrameSize: number;
43
+ }
44
+
30
45
  /**
31
46
  * Streaming frame decoder. Feed it chunks and it yields complete messages.
32
47
  */
33
48
  export class FrameDecoder {
49
+ /**
50
+ * @param opts.maxFrameSize Maximum allowed claimed frame length in bytes.
51
+ * Defaults to DEFAULT_MAX_FRAME_SIZE.
52
+ */
53
+ constructor(opts?: { maxFrameSize?: number });
54
+
34
55
  /**
35
56
  * Feed bytes and return decoded messages.
57
+ * @throws {FrameSizeError} if a frame's claimed length exceeds the
58
+ * configured maximum.
36
59
  */
37
60
  feed(chunk: Uint8Array): unknown[];
38
61
 
package/src/index.mjs CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  // CBOR codec + framing
9
- export { cborEncode, cborDecode, frameEncode, FrameDecoder } from './cbor.mjs';
9
+ export { cborEncode, cborDecode, frameEncode, FrameDecoder, FrameSizeError, DEFAULT_MAX_FRAME_SIZE } from './cbor.mjs';
10
10
 
11
11
  // Protocol messages
12
12
  export {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * wsh protocol control message types and constructors.
3
3
  * AUTO-GENERATED from wsh-v1.yaml — do not edit.
4
- * Run: node web/packages/wsh/spec/codegen.mjs
4
+ * Run: node spec/codegen.mjs (from the @johnhenry/wsh repo root)
5
5
  */
6
6
 
7
7
  // ── Message type constants ────────────────────────────────────────────
@@ -15,10 +15,18 @@
15
15
  * Data streams carry raw bytes with no additional framing.
16
16
  */
17
17
 
18
- import { frameEncode, FrameDecoder } from './cbor.mjs';
18
+ import { frameEncode, FrameDecoder, FrameSizeError } from './cbor.mjs';
19
19
  import { WshTransport } from './transport.mjs';
20
20
 
21
21
  // ── Frame type constants ─────────────────────────────────────────────
22
+ //
23
+ // NOTE: these are unrelated to MSG.WS_DATA (0x60) in messages.gen.mjs.
24
+ // WS_DATA is a JS-only, CBOR-layer bookkeeping marker (it shares its
25
+ // opcode with MSG.DETACH and is explicitly excluded from the Rust
26
+ // message enum by spec/codegen.mjs) — it is never sent as the outer
27
+ // envelope's frame-type byte below. See spec/wsh-v1.yaml's
28
+ // `transport.websocket` section for the authoritative outer-envelope
29
+ // framing docs.
22
30
 
23
31
  const FRAME_CONTROL = 0x01;
24
32
  const FRAME_DATA = 0x02;
@@ -198,6 +206,27 @@ export class WebSocketTransport extends WshTransport {
198
206
  /** Tracks whether we initiated the close. */
199
207
  #closedByUs = false;
200
208
 
209
+ /**
210
+ * Queue of raw inbound messages awaiting dispatch, plus a drain-in-progress
211
+ * flag. Needed because some `WebSocket` implementations (notably Node's
212
+ * `ws` package parsing several frames out of one TCP read) can fire
213
+ * multiple synchronous `message` events within a single JS task, all
214
+ * before any microtask gets to run. If we dispatched each message inline,
215
+ * a handler that resolves a pending waiter (e.g. SERVER_HELLO resolving
216
+ * the "wait for SERVER_HELLO or CHALLENGE" promise) wouldn't get a chance
217
+ * to run its `await`'d continuation — which registers the *next* waiter
218
+ * (e.g. for CHALLENGE) — before the next queued message arrives. That
219
+ * continuation is a microtask; a same-task, no-yield dispatch loop starves
220
+ * it, so the next message's waiter isn't registered yet and gets dropped.
221
+ * Draining the queue with an `await Promise.resolve()` between each
222
+ * message yields to the microtask queue after every dispatch, and since
223
+ * microtasks run FIFO, any continuation queued by handling message N
224
+ * (which runs first) completes before message N+1 is dispatched.
225
+ * @type {ArrayBuffer[]}
226
+ */
227
+ #messageQueue = [];
228
+ #draining = false;
229
+
201
230
  // ── Lifecycle ──────────────────────────────────────────────────────
202
231
 
203
232
  /** @override */
@@ -207,6 +236,8 @@ export class WebSocketTransport extends WshTransport {
207
236
  this.#nextLocalId = 1;
208
237
  this.#openResolvers.clear();
209
238
  this.#closedByUs = false;
239
+ this.#messageQueue.length = 0;
240
+ this.#draining = false;
210
241
 
211
242
  return new Promise((resolve, reject) => {
212
243
  // Normalize URL scheme: wsh:// → wss://, http:// → ws://.
@@ -234,7 +265,8 @@ export class WebSocketTransport extends WshTransport {
234
265
  });
235
266
 
236
267
  ws.addEventListener('message', (ev) => {
237
- this.#handleMessage(ev.data);
268
+ this.#messageQueue.push(ev.data);
269
+ this.#drainMessageQueue();
238
270
  });
239
271
  });
240
272
  }
@@ -287,6 +319,29 @@ export class WebSocketTransport extends WshTransport {
287
319
 
288
320
  // ── Inbound message dispatch ───────────────────────────────────────
289
321
 
322
+ /**
323
+ * Drain `#messageQueue` one message at a time, yielding to the microtask
324
+ * queue between each dispatch. See the `#messageQueue` field doc for why:
325
+ * this is what lets a waiter registered by an earlier message's `await`
326
+ * continuation (e.g. registering the CHALLENGE waiter after SERVER_HELLO
327
+ * resolves) actually be in place before the next queued message arrives,
328
+ * even when the underlying WebSocket implementation delivered several
329
+ * messages synchronously in one task.
330
+ */
331
+ async #drainMessageQueue() {
332
+ if (this.#draining) return;
333
+ this.#draining = true;
334
+ try {
335
+ while (this.#messageQueue.length > 0) {
336
+ const raw = this.#messageQueue.shift();
337
+ this.#handleMessage(raw);
338
+ await Promise.resolve();
339
+ }
340
+ } finally {
341
+ this.#draining = false;
342
+ }
343
+ }
344
+
290
345
  /**
291
346
  * Handle a raw WebSocket message (ArrayBuffer).
292
347
  * @param {ArrayBuffer} raw
@@ -329,7 +384,18 @@ export class WebSocketTransport extends WshTransport {
329
384
  * @param {Uint8Array} payload
330
385
  */
331
386
  #handleControlFrame(payload) {
332
- const messages = this.#decoder.feed(payload);
387
+ let messages;
388
+ try {
389
+ messages = this.#decoder.feed(payload);
390
+ } catch (err) {
391
+ this._emitError(err);
392
+ if (err instanceof FrameSizeError) {
393
+ // Oversized claimed frame length — treat as a hostile or badly
394
+ // broken peer and tear down the connection rather than continue.
395
+ this.close().catch(() => {});
396
+ }
397
+ return;
398
+ }
333
399
  for (const msg of messages) {
334
400
  this._emitControl(msg);
335
401
  }
package/src/transport.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * Data streams carry raw bytes with no framing overhead.
6
6
  */
7
7
 
8
- import { frameEncode, FrameDecoder, cborEncode } from './cbor.mjs';
8
+ import { frameEncode, FrameDecoder, cborEncode, FrameSizeError } from './cbor.mjs';
9
9
 
10
10
  // ── Transport states ─────────────────────────────────────────────────
11
11
 
@@ -281,11 +281,29 @@ export class WebTransportTransport extends WshTransport {
281
281
  const messages = this.#decoder.feed(value);
282
282
  for (const msg of messages) {
283
283
  this._emitControl(msg);
284
+ // Yield to the microtask queue between messages. A QUIC stream
285
+ // has no per-message framing at the transport layer, so a single
286
+ // read() can return bytes for several messages at once (e.g.
287
+ // SERVER_HELLO immediately followed by CHALLENGE). Emitting them
288
+ // in a tight synchronous loop starves any `await`'d continuation
289
+ // triggered by handling an earlier message (e.g. registering the
290
+ // CHALLENGE waiter after SERVER_HELLO resolves) — that
291
+ // continuation is a microtask, and without a yield here it never
292
+ // gets to run before the next message is dispatched. Since
293
+ // microtasks run FIFO, a continuation queued while handling msg
294
+ // N always completes before this yield's own continuation
295
+ // resumes to dispatch msg N+1.
296
+ await Promise.resolve();
284
297
  }
285
298
  }
286
299
  } catch (err) {
287
300
  if (!this.#abort.signal.aborted) {
288
301
  this._emitError(new Error(`Control stream read error: ${err.message}`));
302
+ if (err instanceof FrameSizeError) {
303
+ // Oversized claimed frame length — treat as a hostile or badly
304
+ // broken peer and tear down the connection rather than continue.
305
+ this.close().catch(() => {});
306
+ }
289
307
  }
290
308
  } finally {
291
309
  reader.releaseLock();