@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/README.md +67 -9
- package/package.json +2 -2
- package/src/e2e-frame.mjs +181 -0
- package/src/index.d.ts +483 -9
- package/src/messages.gen.mjs +2 -1
- package/src/session.mjs +274 -5
- package/src/stream-frame.mjs +372 -0
- package/src/virtual-session.mjs +20 -1
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)
|
|
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
|
-
|
|
598
|
-
this
|
|
599
|
-
|
|
600
|
-
|
|
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
|
}
|