@johnhenry/wsh 0.0.0 → 0.2.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.2.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;
@@ -329,7 +337,18 @@ export class WebSocketTransport extends WshTransport {
329
337
  * @param {Uint8Array} payload
330
338
  */
331
339
  #handleControlFrame(payload) {
332
- const messages = this.#decoder.feed(payload);
340
+ let messages;
341
+ try {
342
+ messages = this.#decoder.feed(payload);
343
+ } catch (err) {
344
+ this._emitError(err);
345
+ if (err instanceof FrameSizeError) {
346
+ // Oversized claimed frame length — treat as a hostile or badly
347
+ // broken peer and tear down the connection rather than continue.
348
+ this.close().catch(() => {});
349
+ }
350
+ return;
351
+ }
333
352
  for (const msg of messages) {
334
353
  this._emitControl(msg);
335
354
  }
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
 
@@ -286,6 +286,11 @@ export class WebTransportTransport extends WshTransport {
286
286
  } catch (err) {
287
287
  if (!this.#abort.signal.aborted) {
288
288
  this._emitError(new Error(`Control stream read error: ${err.message}`));
289
+ if (err instanceof FrameSizeError) {
290
+ // Oversized claimed frame length — treat as a hostile or badly
291
+ // broken peer and tear down the connection rather than continue.
292
+ this.close().catch(() => {});
293
+ }
289
294
  }
290
295
  } finally {
291
296
  reader.releaseLock();