@johnhenry/wsh 0.14.0 → 0.16.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/session.mjs CHANGED
@@ -9,6 +9,10 @@ import {
9
9
  MSG, resize as resizeMsg, signal as signalMsg, close as closeMsg,
10
10
  } from './messages.mjs';
11
11
  import { WshVirtualSessionBackend } from './virtual-session.mjs';
12
+ import { sealFrame, openFrame, ROLE_TAGS } from './e2e-frame.mjs';
13
+ import {
14
+ ChunkAccumulator, encodeChunk, resolveCoalesceOptions, WriteCoalescer, StreamAuthenticationError,
15
+ } from './stream-frame.mjs';
12
16
 
13
17
  // ── Session states ────────────────────────────────────────────────────
14
18
 
@@ -77,6 +81,43 @@ export class WshSession {
77
81
  /** @type {WshVirtualSessionBackend|null} */
78
82
  #virtualBackend = null;
79
83
 
84
+ /**
85
+ * Non-extractable AES-256-GCM CryptoKey set by `enableE2E()`, or `null`
86
+ * if E2E is not enabled for this session. Connection-scoped -- see
87
+ * `enableE2E`'s doc comment for why this must never be reused across a
88
+ * Resume/Attach.
89
+ * @type {CryptoKey|null}
90
+ */
91
+ #e2eKey = null;
92
+
93
+ /** @type {Uint8Array|null} This side's 4-byte nonce role tag, set by `enableE2E()`. */
94
+ #e2eSendRoleTag = null;
95
+
96
+ /** @type {Uint8Array|null} The peer's 4-byte nonce role tag, set by `enableE2E()`. */
97
+ #e2eRecvRoleTag = null;
98
+
99
+ /** @type {number} Next outgoing frame counter for this side. */
100
+ #e2eSendCounter = 0;
101
+
102
+ /** @type {number} Next expected incoming frame counter from the peer. */
103
+ #e2eRecvCounter = 0;
104
+
105
+ /**
106
+ * Reassembles the raw stdout byte stream into complete sealed chunks
107
+ * when E2E is enabled on a stream-mode session. `null` for virtual-mode
108
+ * sessions or when E2E is not enabled.
109
+ * @type {import('./stream-frame.mjs').ChunkAccumulator|null}
110
+ */
111
+ #streamAccumulator = null;
112
+
113
+ /**
114
+ * Batches outgoing stream-mode writes before sealing, per the
115
+ * write-coalescing design (wsh #22). `null` means coalescing is
116
+ * disabled (`coalesce: false`) -- every write() seals immediately.
117
+ * @type {WriteCoalescer|null}
118
+ */
119
+ #streamCoalescer = null;
120
+
80
121
  /**
81
122
  * The readable side of the stdout data stream.
82
123
  * @type {ReadableStream<Uint8Array>|null}
@@ -292,6 +333,140 @@ export class WshSession {
292
333
  this.#state = STATE_ACTIVE;
293
334
  }
294
335
 
336
+ /** Whether `enableE2E()` has been called and E2E sealing is active for this session. */
337
+ get e2eEnabled() {
338
+ return this.#e2eKey !== null;
339
+ }
340
+
341
+ /**
342
+ * Opt in to end-to-end encryption for this session's data plane.
343
+ * Works for both virtual-mode and stream-mode sessions (wsh #22
344
+ * generalized this from virtual-mode-only, #19).
345
+ *
346
+ * Virtual-mode: after this call, `write()` seals outgoing data into
347
+ * `EncryptedFrame` control messages instead of plaintext
348
+ * `SessionData`, and incoming `EncryptedFrame` messages are opened and
349
+ * delivered via `onData` exactly like `SessionData` is today;
350
+ * plaintext `SessionData` from a peer is no longer expected once this
351
+ * side has opted in (it's simply passed through unchanged, as before
352
+ * -- this method doesn't add any negotiation-failure handling; that's
353
+ * a later PR).
354
+ *
355
+ * Stream-mode: outgoing bytes are sealed and framed inline in the raw
356
+ * byte stream (`stream-frame.mjs`'s `[len][nonce][ciphertext]` chunk
357
+ * format, reusing the same `sealFrame`/`openFrame` primitives) --
358
+ * invisible to the control-message spec, no new CBOR message type.
359
+ * Small writes are batched before sealing per `opts.coalesce` (see
360
+ * below); incoming bytes are reassembled via a `ChunkAccumulator` and
361
+ * opened per-chunk before reaching `onData`.
362
+ *
363
+ * IMPORTANT -- key lifetime: `sharedSecret` must come from a *fresh*
364
+ * `WshClient.initiateE2E()` call on the *current* connection. E2E
365
+ * state here is connection-scoped, not session-scoped: if this
366
+ * session is later detached and Resumed/Attached on a new connection,
367
+ * callers MUST run `initiateE2E()` again and call `enableE2E()` again
368
+ * with the new key -- never persist or reuse a `sharedSecret` (or its
369
+ * counters) across a resume. Calling `enableE2E()` again on an
370
+ * already-enabled session is fine and resets counters cleanly (a
371
+ * fresh key naturally means fresh counters), but the caller is
372
+ * responsible for actually supplying a fresh key when doing so.
373
+ *
374
+ * @param {CryptoKey} sharedSecret - AES-256-GCM CryptoKey from `WshClient.initiateE2E()`
375
+ * @param {object} opts
376
+ * @param {'initiator'|'responder'} opts.role - which side of the KeyExchange this session was;
377
+ * determines this side's nonce role tag (see `e2e-frame.mjs`'s `ROLE_TAGS`). The two peers of
378
+ * one session MUST pick opposite roles, or their nonces can collide.
379
+ * @param {false|{maxBytes?: number, maxDelayMs?: number}} [opts.coalesce] - stream-mode only:
380
+ * override the default write-coalescing profile derived from this session's `kind`
381
+ * ('pty'|'exec'), or pass `false` to seal every write() immediately with no batching.
382
+ * Ignored for virtual-mode sessions (each write() is already one EncryptedFrame).
383
+ */
384
+ enableE2E(sharedSecret, { role, coalesce } = {}) {
385
+ if (!sharedSecret || typeof sharedSecret !== 'object') {
386
+ throw new Error('enableE2E: sharedSecret must be a CryptoKey (see WshClient.initiateE2E)');
387
+ }
388
+ if (role !== 'initiator' && role !== 'responder') {
389
+ throw new Error("enableE2E: opts.role must be 'initiator' or 'responder'");
390
+ }
391
+ if (!this.#sessionId) {
392
+ throw new Error('enableE2E: session has no server-assigned session_id to bind as AAD -- was OPEN_OK missing session_id?');
393
+ }
394
+ this.#e2eKey = sharedSecret;
395
+ this.#e2eSendRoleTag = ROLE_TAGS[role];
396
+ this.#e2eRecvRoleTag = role === 'initiator' ? ROLE_TAGS.responder : ROLE_TAGS.initiator;
397
+ this.#e2eSendCounter = 0;
398
+ this.#e2eRecvCounter = 0;
399
+
400
+ if (this.#dataMode === 'stream') {
401
+ // Two-data-plane counter isolation note: send/recv counters above
402
+ // are per-session (this instance), not shared with any other data
403
+ // plane -- a session is always exactly one of stream/virtual today,
404
+ // so there's nothing to cross-wire, but a future refactor that let
405
+ // one session span both planes simultaneously would need separate
406
+ // counters per plane.
407
+ this.#streamAccumulator = new ChunkAccumulator();
408
+ const profile = resolveCoalesceOptions(this.kind, coalesce);
409
+ this.#streamCoalescer = profile
410
+ ? new WriteCoalescer(profile, (bytes) => this.#sealAndWriteStreamChunk(bytes))
411
+ : null;
412
+ } else {
413
+ this.#streamAccumulator = null;
414
+ this.#streamCoalescer = null;
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Seal one stream-mode chunk and write it to the stdin stream. Used
420
+ * directly by write() when coalescing is disabled, and as the flush
421
+ * callback for `#streamCoalescer` otherwise.
422
+ * @private
423
+ */
424
+ async #sealAndWriteStreamChunk(bytes) {
425
+ if (this.#stdinWriter === null) {
426
+ throw new Error('Session not yet bound — stdin writer unavailable');
427
+ }
428
+ const counter = this.#e2eSendCounter++;
429
+ const { nonce, ciphertext } = await sealFrame(
430
+ this.#e2eKey, this.#sessionId, this.#e2eSendRoleTag, counter, bytes
431
+ );
432
+ await this.#stdinWriter.write(encodeChunk(nonce, ciphertext));
433
+ }
434
+
435
+ /**
436
+ * Feed newly-read bytes through the stream E2E accumulator and open
437
+ * every complete chunk it yields.
438
+ *
439
+ * @param {Uint8Array} bytes
440
+ * @returns {Promise<Uint8Array[]|null>} the opened plaintext chunks, in order,
441
+ * or `null` if framing was corrupt or a chunk failed authentication
442
+ * (already logged; caller should tear the pump down).
443
+ * @private
444
+ */
445
+ async #openStreamChunks(bytes) {
446
+ let wireChunks;
447
+ try {
448
+ wireChunks = this.#streamAccumulator.feed(bytes);
449
+ } catch (err) {
450
+ console.error('[wsh:session] stream E2E framing error:', err);
451
+ return null;
452
+ }
453
+ const plaintexts = [];
454
+ for (const { nonce, ciphertext } of wireChunks) {
455
+ const counter = this.#e2eRecvCounter++;
456
+ try {
457
+ const plaintext = await openFrame(this.#e2eKey, this.#sessionId, counter, { nonce, ciphertext });
458
+ plaintexts.push(plaintext);
459
+ } catch (err) {
460
+ console.error(
461
+ '[wsh:session] stream E2E chunk failed authentication:',
462
+ new StreamAuthenticationError(err.message)
463
+ );
464
+ return null;
465
+ }
466
+ }
467
+ return plaintexts;
468
+ }
469
+
295
470
  // ── Public API ──────────────────────────────────────────────────────
296
471
 
297
472
  /**
@@ -308,6 +483,15 @@ export class WshSession {
308
483
  if (this.#virtualBackend === null) {
309
484
  throw new Error('Session not yet activated — virtual backend unavailable');
310
485
  }
486
+ if (this.#e2eKey !== null) {
487
+ const bytes = typeof data === 'string' ? textEncoder.encode(data) : data;
488
+ const counter = this.#e2eSendCounter++;
489
+ const { nonce, ciphertext } = await sealFrame(
490
+ this.#e2eKey, this.#sessionId, this.#e2eSendRoleTag, counter, bytes
491
+ );
492
+ await this.#virtualBackend.writeEncrypted({ nonce, ciphertext, sessionId: this.#sessionId });
493
+ return;
494
+ }
311
495
  await this.#virtualBackend.write(data);
312
496
  return;
313
497
  }
@@ -316,6 +500,16 @@ export class WshSession {
316
500
  }
317
501
 
318
502
  const bytes = typeof data === 'string' ? textEncoder.encode(data) : data;
503
+
504
+ if (this.#e2eKey !== null) {
505
+ if (this.#streamCoalescer) {
506
+ await this.#streamCoalescer.write(bytes);
507
+ } else {
508
+ await this.#sealAndWriteStreamChunk(bytes);
509
+ }
510
+ return;
511
+ }
512
+
319
513
  await this.#stdinWriter.write(bytes);
320
514
  }
321
515
 
@@ -397,6 +591,16 @@ export class WshSession {
397
591
  }
398
592
 
399
593
  if (this.#dataMode === 'stream') {
594
+ // Flush any bytes still batched by write-coalescing before closing
595
+ // the writer, so a pending buffer isn't silently dropped.
596
+ if (this.#streamCoalescer) {
597
+ try {
598
+ await this.#streamCoalescer.flush();
599
+ } catch (err) {
600
+ console.error('[wsh:session] failed to flush coalesced stream E2E writes on close:', err);
601
+ }
602
+ }
603
+
400
604
  // Release the stdin writer.
401
605
  try {
402
606
  await this.#stdinWriter?.close();
@@ -437,6 +641,40 @@ export class WshSession {
437
641
  break;
438
642
  }
439
643
 
644
+ case MSG.ENCRYPTED_FRAME: {
645
+ if (this.#e2eKey === null) {
646
+ console.error('[wsh:session] received EncryptedFrame but E2E is not enabled on this session — dropping');
647
+ break;
648
+ }
649
+ if (msg.session_id !== this.#sessionId) {
650
+ console.error('[wsh:session] EncryptedFrame session_id mismatch — dropping (possible splice attempt)');
651
+ break;
652
+ }
653
+ // openFrame is async; _handleControlMessage is a synchronous
654
+ // dispatch entry point (see the SESSION_DATA case above and its
655
+ // callers), so this fires the decrypt-and-deliver as a
656
+ // background task rather than awaiting it here. Per-channel
657
+ // control messages are already dispatched serially by the
658
+ // caller (see transport.mjs's dispatchSerially/SerialQueue), so
659
+ // frames still arrive at openFrame() in wire order; only the
660
+ // delivery of *this* frame's plaintext to onData is deferred by
661
+ // one microtask/macrotask relative to synchronous cases.
662
+ const counter = this.#e2eRecvCounter++;
663
+ openFrame(this.#e2eKey, this.#sessionId, counter, { nonce: msg.nonce, ciphertext: msg.ciphertext })
664
+ .then((plaintext) => {
665
+ this.#virtualBackend?.pushData(plaintext);
666
+ try {
667
+ this.onData?.(plaintext);
668
+ } catch (err) {
669
+ console.error('[wsh:session] onData handler error:', err);
670
+ }
671
+ })
672
+ .catch((err) => {
673
+ console.error('[wsh:session] failed to open EncryptedFrame:', err);
674
+ });
675
+ break;
676
+ }
677
+
440
678
  case MSG.FILE_CHUNK: {
441
679
  if (this.#fileChunkWaiter) {
442
680
  const { resolve } = this.#fileChunkWaiter;
@@ -591,13 +829,44 @@ export class WshSession {
591
829
  if (this.#abort.signal.aborted) break;
592
830
 
593
831
  const { done, value } = await reader.read();
594
- if (done) break;
832
+ if (done) {
833
+ // Clean EOF: catch a torn (truncated) chunk left in the E2E
834
+ // accumulator, if any -- per the design, no partial-chunk
835
+ // plaintext is ever released, so this is purely a diagnostic
836
+ // (the stream is ending either way).
837
+ if (this.#e2eKey !== null && this.#streamAccumulator) {
838
+ try {
839
+ this.#streamAccumulator.finish();
840
+ } catch (err) {
841
+ console.error('[wsh:session] stream E2E torn chunk at stream end:', err);
842
+ }
843
+ }
844
+ break;
845
+ }
595
846
 
596
847
  if (value && value.byteLength > 0) {
597
- try {
598
- this.onData?.(value);
599
- } catch (err) {
600
- console.error('[wsh:session] onData handler error:', err);
848
+ if (this.#e2eKey !== null && this.#streamAccumulator) {
849
+ const deliverable = await this.#openStreamChunks(value);
850
+ if (deliverable === null) {
851
+ // Authentication failure (or corrupt framing) -- matches
852
+ // the strict "no skip-and-continue" philosophy of
853
+ // virtual-mode's counter check: tear the session down
854
+ // rather than deliver anything from this point on.
855
+ break;
856
+ }
857
+ for (const plaintext of deliverable) {
858
+ try {
859
+ this.onData?.(plaintext);
860
+ } catch (err) {
861
+ console.error('[wsh:session] onData handler error:', err);
862
+ }
863
+ }
864
+ } else {
865
+ try {
866
+ this.onData?.(value);
867
+ } catch (err) {
868
+ console.error('[wsh:session] onData handler error:', err);
869
+ }
601
870
  }
602
871
  }
603
872
  }