@johnhenry/wsh 0.15.0 → 0.16.1
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/package.json +1 -1
- package/src/e2e-frame.mjs +9 -5
- package/src/session.mjs +228 -26
- package/src/stream-frame.mjs +372 -0
package/package.json
CHANGED
package/src/e2e-frame.mjs
CHANGED
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
* `CryptoKey` this module seals/opens with, and `spec/wsh-v1.yaml`'s
|
|
5
5
|
* `EncryptedFrame` message).
|
|
6
6
|
*
|
|
7
|
-
* This
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* This module's AEAD primitives were introduced in wsh #19 (virtual-mode
|
|
8
|
+
* only) and, as of wsh #22 (PR 1 of 3), are reused unchanged by
|
|
9
|
+
* stream-mode sessions too: `stream-frame.mjs` wraps `sealFrame`/
|
|
10
|
+
* `openFrame` in inline length-prefixed chunk framing for raw
|
|
11
|
+
* WebTransport byte streams (no message boundaries), while
|
|
12
|
+
* `session.mjs`'s `enableE2E` now dispatches to either transport based
|
|
13
|
+
* on the session's `dataMode`. This module itself needed no changes for
|
|
14
|
+
* that -- see `stream-frame.mjs`'s doc comment for the chunk-framing
|
|
15
|
+
* design.
|
|
12
16
|
*
|
|
13
17
|
* ── Nonce construction ─────────────────────────────────────────────
|
|
14
18
|
* Nonces are the AES-GCM-mandated 96 bits (12 bytes), built as:
|
package/src/session.mjs
CHANGED
|
@@ -10,6 +10,9 @@ import {
|
|
|
10
10
|
} from './messages.mjs';
|
|
11
11
|
import { WshVirtualSessionBackend } from './virtual-session.mjs';
|
|
12
12
|
import { sealFrame, openFrame, ROLE_TAGS } from './e2e-frame.mjs';
|
|
13
|
+
import {
|
|
14
|
+
ChunkAccumulator, encodeChunk, resolveCoalesceOptions, WriteCoalescer, StreamAuthenticationError,
|
|
15
|
+
} from './stream-frame.mjs';
|
|
13
16
|
|
|
14
17
|
// ── Session states ────────────────────────────────────────────────────
|
|
15
18
|
|
|
@@ -17,6 +20,17 @@ const STATE_OPENING = 'opening';
|
|
|
17
20
|
const STATE_ACTIVE = 'active';
|
|
18
21
|
const STATE_CLOSED = 'closed';
|
|
19
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Stream-mode data and control streams are independently-multiplexed
|
|
25
|
+
* transport streams (QUIC/QMux) with no guaranteed relative delivery
|
|
26
|
+
* order (wsh #24). When the data stream reaches EOF, the server-sent
|
|
27
|
+
* `CLOSE` control message -- not the data-stream FIN -- is the
|
|
28
|
+
* authoritative close signal, so `_pumpDataStream()` waits up to this
|
|
29
|
+
* long for `CLOSE` to arrive before falling back to closing on data-EOF
|
|
30
|
+
* alone (a safety net for servers that never send `CLOSE`).
|
|
31
|
+
*/
|
|
32
|
+
const DATA_EOF_CLOSE_GRACE_MS = 300;
|
|
33
|
+
|
|
20
34
|
// ── Text encoding ─────────────────────────────────────────────────────
|
|
21
35
|
|
|
22
36
|
const textEncoder = new TextEncoder();
|
|
@@ -99,6 +113,22 @@ export class WshSession {
|
|
|
99
113
|
/** @type {number} Next expected incoming frame counter from the peer. */
|
|
100
114
|
#e2eRecvCounter = 0;
|
|
101
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Reassembles the raw stdout byte stream into complete sealed chunks
|
|
118
|
+
* when E2E is enabled on a stream-mode session. `null` for virtual-mode
|
|
119
|
+
* sessions or when E2E is not enabled.
|
|
120
|
+
* @type {import('./stream-frame.mjs').ChunkAccumulator|null}
|
|
121
|
+
*/
|
|
122
|
+
#streamAccumulator = null;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Batches outgoing stream-mode writes before sealing, per the
|
|
126
|
+
* write-coalescing design (wsh #22). `null` means coalescing is
|
|
127
|
+
* disabled (`coalesce: false`) -- every write() seals immediately.
|
|
128
|
+
* @type {WriteCoalescer|null}
|
|
129
|
+
*/
|
|
130
|
+
#streamCoalescer = null;
|
|
131
|
+
|
|
102
132
|
/**
|
|
103
133
|
* The readable side of the stdout data stream.
|
|
104
134
|
* @type {ReadableStream<Uint8Array>|null}
|
|
@@ -114,6 +144,16 @@ export class WshSession {
|
|
|
114
144
|
/** @type {number|null} Exit code received from the server. */
|
|
115
145
|
#exitCode = null;
|
|
116
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Pending fallback timer scheduled when the data stream hits EOF
|
|
149
|
+
* before a `CLOSE` control message has arrived (wsh #24). Cleared
|
|
150
|
+
* as soon as `CLOSE` arrives (or the session is otherwise closed) so
|
|
151
|
+
* a prompt `CLOSE` short-circuits the wait instead of idling out the
|
|
152
|
+
* full grace period.
|
|
153
|
+
* @type {ReturnType<typeof setTimeout>|null}
|
|
154
|
+
*/
|
|
155
|
+
#closeGraceTimer = null;
|
|
156
|
+
|
|
117
157
|
// ── Callbacks ───────────────────────────────────────────────────────
|
|
118
158
|
|
|
119
159
|
/**
|
|
@@ -320,16 +360,26 @@ export class WshSession {
|
|
|
320
360
|
}
|
|
321
361
|
|
|
322
362
|
/**
|
|
323
|
-
* Opt in to end-to-end encryption for this session's data plane
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
363
|
+
* Opt in to end-to-end encryption for this session's data plane.
|
|
364
|
+
* Works for both virtual-mode and stream-mode sessions (wsh #22
|
|
365
|
+
* generalized this from virtual-mode-only, #19).
|
|
366
|
+
*
|
|
367
|
+
* Virtual-mode: after this call, `write()` seals outgoing data into
|
|
368
|
+
* `EncryptedFrame` control messages instead of plaintext
|
|
369
|
+
* `SessionData`, and incoming `EncryptedFrame` messages are opened and
|
|
370
|
+
* delivered via `onData` exactly like `SessionData` is today;
|
|
371
|
+
* plaintext `SessionData` from a peer is no longer expected once this
|
|
372
|
+
* side has opted in (it's simply passed through unchanged, as before
|
|
373
|
+
* -- this method doesn't add any negotiation-failure handling; that's
|
|
374
|
+
* a later PR).
|
|
375
|
+
*
|
|
376
|
+
* Stream-mode: outgoing bytes are sealed and framed inline in the raw
|
|
377
|
+
* byte stream (`stream-frame.mjs`'s `[len][nonce][ciphertext]` chunk
|
|
378
|
+
* format, reusing the same `sealFrame`/`openFrame` primitives) --
|
|
379
|
+
* invisible to the control-message spec, no new CBOR message type.
|
|
380
|
+
* Small writes are batched before sealing per `opts.coalesce` (see
|
|
381
|
+
* below); incoming bytes are reassembled via a `ChunkAccumulator` and
|
|
382
|
+
* opened per-chunk before reaching `onData`.
|
|
333
383
|
*
|
|
334
384
|
* IMPORTANT -- key lifetime: `sharedSecret` must come from a *fresh*
|
|
335
385
|
* `WshClient.initiateE2E()` call on the *current* connection. E2E
|
|
@@ -347,17 +397,18 @@ export class WshSession {
|
|
|
347
397
|
* @param {'initiator'|'responder'} opts.role - which side of the KeyExchange this session was;
|
|
348
398
|
* determines this side's nonce role tag (see `e2e-frame.mjs`'s `ROLE_TAGS`). The two peers of
|
|
349
399
|
* one session MUST pick opposite roles, or their nonces can collide.
|
|
400
|
+
* @param {false|{maxBytes?: number, maxDelayMs?: number}} [opts.coalesce] - stream-mode only:
|
|
401
|
+
* override the default write-coalescing profile derived from this session's `kind`
|
|
402
|
+
* ('pty'|'exec'), or pass `false` to seal every write() immediately with no batching.
|
|
403
|
+
* Ignored for virtual-mode sessions (each write() is already one EncryptedFrame).
|
|
350
404
|
*/
|
|
351
|
-
enableE2E(sharedSecret, { role } = {}) {
|
|
405
|
+
enableE2E(sharedSecret, { role, coalesce } = {}) {
|
|
352
406
|
if (!sharedSecret || typeof sharedSecret !== 'object') {
|
|
353
407
|
throw new Error('enableE2E: sharedSecret must be a CryptoKey (see WshClient.initiateE2E)');
|
|
354
408
|
}
|
|
355
409
|
if (role !== 'initiator' && role !== 'responder') {
|
|
356
410
|
throw new Error("enableE2E: opts.role must be 'initiator' or 'responder'");
|
|
357
411
|
}
|
|
358
|
-
if (this.#dataMode !== 'virtual') {
|
|
359
|
-
throw new Error('enableE2E: only virtual-mode sessions are supported in this version (stream-mode is out of scope)');
|
|
360
|
-
}
|
|
361
412
|
if (!this.#sessionId) {
|
|
362
413
|
throw new Error('enableE2E: session has no server-assigned session_id to bind as AAD -- was OPEN_OK missing session_id?');
|
|
363
414
|
}
|
|
@@ -366,6 +417,75 @@ export class WshSession {
|
|
|
366
417
|
this.#e2eRecvRoleTag = role === 'initiator' ? ROLE_TAGS.responder : ROLE_TAGS.initiator;
|
|
367
418
|
this.#e2eSendCounter = 0;
|
|
368
419
|
this.#e2eRecvCounter = 0;
|
|
420
|
+
|
|
421
|
+
if (this.#dataMode === 'stream') {
|
|
422
|
+
// Two-data-plane counter isolation note: send/recv counters above
|
|
423
|
+
// are per-session (this instance), not shared with any other data
|
|
424
|
+
// plane -- a session is always exactly one of stream/virtual today,
|
|
425
|
+
// so there's nothing to cross-wire, but a future refactor that let
|
|
426
|
+
// one session span both planes simultaneously would need separate
|
|
427
|
+
// counters per plane.
|
|
428
|
+
this.#streamAccumulator = new ChunkAccumulator();
|
|
429
|
+
const profile = resolveCoalesceOptions(this.kind, coalesce);
|
|
430
|
+
this.#streamCoalescer = profile
|
|
431
|
+
? new WriteCoalescer(profile, (bytes) => this.#sealAndWriteStreamChunk(bytes))
|
|
432
|
+
: null;
|
|
433
|
+
} else {
|
|
434
|
+
this.#streamAccumulator = null;
|
|
435
|
+
this.#streamCoalescer = null;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Seal one stream-mode chunk and write it to the stdin stream. Used
|
|
441
|
+
* directly by write() when coalescing is disabled, and as the flush
|
|
442
|
+
* callback for `#streamCoalescer` otherwise.
|
|
443
|
+
* @private
|
|
444
|
+
*/
|
|
445
|
+
async #sealAndWriteStreamChunk(bytes) {
|
|
446
|
+
if (this.#stdinWriter === null) {
|
|
447
|
+
throw new Error('Session not yet bound — stdin writer unavailable');
|
|
448
|
+
}
|
|
449
|
+
const counter = this.#e2eSendCounter++;
|
|
450
|
+
const { nonce, ciphertext } = await sealFrame(
|
|
451
|
+
this.#e2eKey, this.#sessionId, this.#e2eSendRoleTag, counter, bytes
|
|
452
|
+
);
|
|
453
|
+
await this.#stdinWriter.write(encodeChunk(nonce, ciphertext));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Feed newly-read bytes through the stream E2E accumulator and open
|
|
458
|
+
* every complete chunk it yields.
|
|
459
|
+
*
|
|
460
|
+
* @param {Uint8Array} bytes
|
|
461
|
+
* @returns {Promise<Uint8Array[]|null>} the opened plaintext chunks, in order,
|
|
462
|
+
* or `null` if framing was corrupt or a chunk failed authentication
|
|
463
|
+
* (already logged; caller should tear the pump down).
|
|
464
|
+
* @private
|
|
465
|
+
*/
|
|
466
|
+
async #openStreamChunks(bytes) {
|
|
467
|
+
let wireChunks;
|
|
468
|
+
try {
|
|
469
|
+
wireChunks = this.#streamAccumulator.feed(bytes);
|
|
470
|
+
} catch (err) {
|
|
471
|
+
console.error('[wsh:session] stream E2E framing error:', err);
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
const plaintexts = [];
|
|
475
|
+
for (const { nonce, ciphertext } of wireChunks) {
|
|
476
|
+
const counter = this.#e2eRecvCounter++;
|
|
477
|
+
try {
|
|
478
|
+
const plaintext = await openFrame(this.#e2eKey, this.#sessionId, counter, { nonce, ciphertext });
|
|
479
|
+
plaintexts.push(plaintext);
|
|
480
|
+
} catch (err) {
|
|
481
|
+
console.error(
|
|
482
|
+
'[wsh:session] stream E2E chunk failed authentication:',
|
|
483
|
+
new StreamAuthenticationError(err.message)
|
|
484
|
+
);
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return plaintexts;
|
|
369
489
|
}
|
|
370
490
|
|
|
371
491
|
// ── Public API ──────────────────────────────────────────────────────
|
|
@@ -401,6 +521,16 @@ export class WshSession {
|
|
|
401
521
|
}
|
|
402
522
|
|
|
403
523
|
const bytes = typeof data === 'string' ? textEncoder.encode(data) : data;
|
|
524
|
+
|
|
525
|
+
if (this.#e2eKey !== null) {
|
|
526
|
+
if (this.#streamCoalescer) {
|
|
527
|
+
await this.#streamCoalescer.write(bytes);
|
|
528
|
+
} else {
|
|
529
|
+
await this.#sealAndWriteStreamChunk(bytes);
|
|
530
|
+
}
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
|
|
404
534
|
await this.#stdinWriter.write(bytes);
|
|
405
535
|
}
|
|
406
536
|
|
|
@@ -471,6 +601,7 @@ export class WshSession {
|
|
|
471
601
|
// Optimistically mark closed so no further writes are accepted.
|
|
472
602
|
this.#state = STATE_CLOSED;
|
|
473
603
|
this.#abort.abort();
|
|
604
|
+
this.#clearCloseGraceTimer();
|
|
474
605
|
|
|
475
606
|
// Send CLOSE to the server (best-effort).
|
|
476
607
|
try {
|
|
@@ -482,6 +613,16 @@ export class WshSession {
|
|
|
482
613
|
}
|
|
483
614
|
|
|
484
615
|
if (this.#dataMode === 'stream') {
|
|
616
|
+
// Flush any bytes still batched by write-coalescing before closing
|
|
617
|
+
// the writer, so a pending buffer isn't silently dropped.
|
|
618
|
+
if (this.#streamCoalescer) {
|
|
619
|
+
try {
|
|
620
|
+
await this.#streamCoalescer.flush();
|
|
621
|
+
} catch (err) {
|
|
622
|
+
console.error('[wsh:session] failed to flush coalesced stream E2E writes on close:', err);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
485
626
|
// Release the stdin writer.
|
|
486
627
|
try {
|
|
487
628
|
await this.#stdinWriter?.close();
|
|
@@ -578,7 +719,11 @@ export class WshSession {
|
|
|
578
719
|
}
|
|
579
720
|
|
|
580
721
|
case MSG.CLOSE: {
|
|
581
|
-
// Server-initiated close.
|
|
722
|
+
// Server-initiated close. This is the authoritative close signal
|
|
723
|
+
// for stream-mode sessions (wsh #24) -- it always short-circuits
|
|
724
|
+
// any pending data-EOF grace timer, regardless of which arrived
|
|
725
|
+
// first.
|
|
726
|
+
this.#clearCloseGraceTimer();
|
|
582
727
|
if (this.#state !== STATE_CLOSED) {
|
|
583
728
|
this.#state = STATE_CLOSED;
|
|
584
729
|
this.#abort.abort();
|
|
@@ -710,13 +855,44 @@ export class WshSession {
|
|
|
710
855
|
if (this.#abort.signal.aborted) break;
|
|
711
856
|
|
|
712
857
|
const { done, value } = await reader.read();
|
|
713
|
-
if (done)
|
|
858
|
+
if (done) {
|
|
859
|
+
// Clean EOF: catch a torn (truncated) chunk left in the E2E
|
|
860
|
+
// accumulator, if any -- per the design, no partial-chunk
|
|
861
|
+
// plaintext is ever released, so this is purely a diagnostic
|
|
862
|
+
// (the stream is ending either way).
|
|
863
|
+
if (this.#e2eKey !== null && this.#streamAccumulator) {
|
|
864
|
+
try {
|
|
865
|
+
this.#streamAccumulator.finish();
|
|
866
|
+
} catch (err) {
|
|
867
|
+
console.error('[wsh:session] stream E2E torn chunk at stream end:', err);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
714
872
|
|
|
715
873
|
if (value && value.byteLength > 0) {
|
|
716
|
-
|
|
717
|
-
this
|
|
718
|
-
|
|
719
|
-
|
|
874
|
+
if (this.#e2eKey !== null && this.#streamAccumulator) {
|
|
875
|
+
const deliverable = await this.#openStreamChunks(value);
|
|
876
|
+
if (deliverable === null) {
|
|
877
|
+
// Authentication failure (or corrupt framing) -- matches
|
|
878
|
+
// the strict "no skip-and-continue" philosophy of
|
|
879
|
+
// virtual-mode's counter check: tear the session down
|
|
880
|
+
// rather than deliver anything from this point on.
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
for (const plaintext of deliverable) {
|
|
884
|
+
try {
|
|
885
|
+
this.onData?.(plaintext);
|
|
886
|
+
} catch (err) {
|
|
887
|
+
console.error('[wsh:session] onData handler error:', err);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
} else {
|
|
891
|
+
try {
|
|
892
|
+
this.onData?.(value);
|
|
893
|
+
} catch (err) {
|
|
894
|
+
console.error('[wsh:session] onData handler error:', err);
|
|
895
|
+
}
|
|
720
896
|
}
|
|
721
897
|
}
|
|
722
898
|
}
|
|
@@ -729,17 +905,43 @@ export class WshSession {
|
|
|
729
905
|
reader.releaseLock();
|
|
730
906
|
}
|
|
731
907
|
|
|
732
|
-
//
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
908
|
+
// The data stream ended, but its FIN carries no ordering guarantee
|
|
909
|
+
// relative to the control stream (wsh #24): a server that closes the
|
|
910
|
+
// data stream and sends EXIT/CLOSE around the same time can have
|
|
911
|
+
// this EOF arrive before those control messages. Closing the session
|
|
912
|
+
// here unconditionally would resolve onClose without onExit ever
|
|
913
|
+
// having fired. Instead, give the (expected) CLOSE control message a
|
|
914
|
+
// bounded grace period to arrive -- its handler clears this timer and
|
|
915
|
+
// performs the real transition -- and only self-close on timeout as a
|
|
916
|
+
// fallback for servers that never send CLOSE after ending the data
|
|
917
|
+
// stream.
|
|
918
|
+
if (this.#state !== STATE_CLOSED && this.#closeGraceTimer === null) {
|
|
919
|
+
this.#closeGraceTimer = setTimeout(() => {
|
|
920
|
+
this.#closeGraceTimer = null;
|
|
921
|
+
if (this.#state !== STATE_CLOSED) {
|
|
922
|
+
this.#state = STATE_CLOSED;
|
|
923
|
+
this.#releaseStreams();
|
|
924
|
+
this.#emitClose();
|
|
925
|
+
}
|
|
926
|
+
}, DATA_EOF_CLOSE_GRACE_MS);
|
|
927
|
+
this.#closeGraceTimer?.unref?.();
|
|
738
928
|
}
|
|
739
929
|
}
|
|
740
930
|
|
|
741
931
|
// ── Private helpers ─────────────────────────────────────────────────
|
|
742
932
|
|
|
933
|
+
/**
|
|
934
|
+
* Cancel any pending data-EOF close-grace timer (wsh #24). Safe to call
|
|
935
|
+
* whether or not a timer is currently scheduled.
|
|
936
|
+
* @private
|
|
937
|
+
*/
|
|
938
|
+
#clearCloseGraceTimer() {
|
|
939
|
+
if (this.#closeGraceTimer !== null) {
|
|
940
|
+
clearTimeout(this.#closeGraceTimer);
|
|
941
|
+
this.#closeGraceTimer = null;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
743
945
|
/**
|
|
744
946
|
* Release stream resources without sending a CLOSE message.
|
|
745
947
|
* @private
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream-mode chunk framing for wsh's opt-in end-to-end encryption layer
|
|
3
|
+
* (see `session.mjs`'s `enableE2E`, which now accepts stream-backed
|
|
4
|
+
* sessions -- this module supplies the framing wrapper that makes that
|
|
5
|
+
* possible; the AEAD primitives themselves live in `e2e-frame.mjs` and
|
|
6
|
+
* are reused unchanged).
|
|
7
|
+
*
|
|
8
|
+
* This is PR 1 of 3 in the stream-mode E2E rollout (wsh #22, follow-up
|
|
9
|
+
* to the virtual-mode rollout in #19). See that issue for the full
|
|
10
|
+
* design doc; the short version below is enough to work on this module.
|
|
11
|
+
*
|
|
12
|
+
* ── Why framing is needed at all ──────────────────────────────────────
|
|
13
|
+
* Stream-mode sessions are raw WebTransport bidirectional byte streams
|
|
14
|
+
* with no message boundaries (`transport.mjs`'s `openStream()`: "Data
|
|
15
|
+
* streams carry raw bytes with no framing overhead"). Virtual-mode reuses
|
|
16
|
+
* the control-message envelope (`EncryptedFrame`) to carry one sealed
|
|
17
|
+
* frame per `SessionData` 1:1, but a byte stream has no such envelope --
|
|
18
|
+
* a single `read()` may return a partial chunk, multiple chunks, or a
|
|
19
|
+
* chunk split across two reads. This module's `ChunkAccumulator` restores
|
|
20
|
+
* chunk boundaries on the read side; `encodeChunk` builds them on the
|
|
21
|
+
* write side.
|
|
22
|
+
*
|
|
23
|
+
* ── Wire format ────────────────────────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* [ 4-byte big-endian length prefix N ][ 12-byte nonce ][ N bytes: ciphertext + 16-byte GCM tag ]
|
|
26
|
+
*
|
|
27
|
+
* - N counts only the ciphertext+tag bytes that follow the nonce (the
|
|
28
|
+
* nonce itself is a fixed 12-byte wire constant, not counted -- avoids
|
|
29
|
+
* an off-by-12 footgun).
|
|
30
|
+
* - The nonce is 12 bytes, clear (not secret -- same convention as
|
|
31
|
+
* virtual-mode's `EncryptedFrame.nonce`); it encodes the same
|
|
32
|
+
* 8-byte-counter + 4-byte-role-tag scheme from `e2e-frame.mjs`, reused
|
|
33
|
+
* verbatim: a stream "chunk index" is exactly the same thing as
|
|
34
|
+
* virtual-mode's "message counter" (see `buildNonce`/`ROLE_TAGS`).
|
|
35
|
+
* - The length field is NOT bound as AEAD additional authenticated data:
|
|
36
|
+
* there's no tampering scenario it prevents that the GCM tag doesn't
|
|
37
|
+
* already catch -- a corrupted length either fails decryption outright
|
|
38
|
+
* or misaligns the *next* chunk's boundary, but plaintext is never
|
|
39
|
+
* released without passing authentication either way.
|
|
40
|
+
* - Chunk size: soft target ~16 KiB plaintext, hard cap 64 KiB (a `u32`
|
|
41
|
+
* length prefix has ample headroom above that). See `resolveCoalesceOptions`
|
|
42
|
+
* for how writes are batched up to (well below) that cap before sealing.
|
|
43
|
+
*
|
|
44
|
+
* ── Failure modes at stream end ───────────────────────────────────────
|
|
45
|
+
* `ChunkAccumulator` distinguishes:
|
|
46
|
+
* 1. Clean EOF -- buffer empty when `finish()` is called.
|
|
47
|
+
* 2. Torn chunk -- partial bytes buffered (not enough for a complete
|
|
48
|
+
* chunk) when `finish()` is called -- throws `StreamTornChunkError`.
|
|
49
|
+
* 3. Failed AEAD authentication on an otherwise-complete chunk -- this
|
|
50
|
+
* is *not* raised by this module (it has no key); callers use
|
|
51
|
+
* `StreamAuthenticationError` to report it after calling `openFrame`
|
|
52
|
+
* from `e2e-frame.mjs` (see `session.mjs`'s stream read path).
|
|
53
|
+
* No partial-chunk plaintext is ever released in any case.
|
|
54
|
+
*
|
|
55
|
+
* ── Efficiency note ────────────────────────────────────────────────────
|
|
56
|
+
* `ChunkAccumulator` uses a cursor-based internal buffer (append via an
|
|
57
|
+
* amortized-doubling growable `Uint8Array`, consume via advancing a read
|
|
58
|
+
* offset) rather than repeatedly `Uint8Array.slice()`-ing the whole
|
|
59
|
+
* buffer on every `feed()` call, which would go quadratic under many
|
|
60
|
+
* small `read()`s. The only per-chunk `slice()` calls are for the two
|
|
61
|
+
* pieces (`nonce`, `ciphertext`) actually handed back to the caller.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
import { sealFrame } from './e2e-frame.mjs';
|
|
65
|
+
|
|
66
|
+
const LENGTH_PREFIX_BYTES = 4;
|
|
67
|
+
const NONCE_BYTES = 12;
|
|
68
|
+
const GCM_TAG_BYTES = 16;
|
|
69
|
+
|
|
70
|
+
/** Soft target plaintext chunk size (see module doc comment). */
|
|
71
|
+
export const CHUNK_SOFT_TARGET_BYTES = 16 * 1024;
|
|
72
|
+
|
|
73
|
+
/** Hard cap on plaintext chunk size; ciphertext+tag never exceeds this + 16. */
|
|
74
|
+
export const CHUNK_HARD_CAP_BYTES = 64 * 1024;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Sanity bound on the wire length prefix, used only to fail fast on an
|
|
78
|
+
* obviously-corrupt/malicious length field (e.g. a torn/garbled header)
|
|
79
|
+
* rather than buffering unboundedly while waiting for a chunk that will
|
|
80
|
+
* never complete. Generous headroom above `CHUNK_HARD_CAP_BYTES` so a
|
|
81
|
+
* legitimate sender is never rejected.
|
|
82
|
+
*/
|
|
83
|
+
const MAX_WIRE_CIPHERTEXT_BYTES = CHUNK_HARD_CAP_BYTES + GCM_TAG_BYTES;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Thrown by `ChunkAccumulator.finish()` when the internal buffer still
|
|
87
|
+
* holds partial (incomplete) chunk bytes at stream EOF -- a truncated
|
|
88
|
+
* stream, not a clean close. Distinct from `StreamAuthenticationError`
|
|
89
|
+
* so callers can tell "the stream ended mid-chunk" apart from "a
|
|
90
|
+
* complete chunk arrived but failed to authenticate".
|
|
91
|
+
*/
|
|
92
|
+
export class StreamTornChunkError extends Error {
|
|
93
|
+
constructor(message) {
|
|
94
|
+
super(message);
|
|
95
|
+
this.name = 'StreamTornChunkError';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Thrown by callers (see `session.mjs`'s stream read path) when a
|
|
101
|
+
* complete, well-framed chunk fails AEAD authentication in
|
|
102
|
+
* `openFrame()` (tampered ciphertext, wrong key, or session_id
|
|
103
|
+
* mismatch). Distinct from `StreamTornChunkError` -- this module itself
|
|
104
|
+
* never throws it, since `ChunkAccumulator` has no key and cannot
|
|
105
|
+
* authenticate anything.
|
|
106
|
+
*/
|
|
107
|
+
export class StreamAuthenticationError extends Error {
|
|
108
|
+
constructor(message) {
|
|
109
|
+
super(message);
|
|
110
|
+
this.name = 'StreamAuthenticationError';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Encode one sealed chunk for the wire: `[len][nonce][ciphertext]`.
|
|
116
|
+
*
|
|
117
|
+
* @param {Uint8Array} nonce - exactly 12 bytes
|
|
118
|
+
* @param {Uint8Array} ciphertext - ciphertext + GCM tag, as returned by `sealFrame`
|
|
119
|
+
* @returns {Uint8Array}
|
|
120
|
+
*/
|
|
121
|
+
export function encodeChunk(nonce, ciphertext) {
|
|
122
|
+
if (!(nonce instanceof Uint8Array) || nonce.length !== NONCE_BYTES) {
|
|
123
|
+
throw new Error(`stream-frame: nonce must be a ${NONCE_BYTES}-byte Uint8Array`);
|
|
124
|
+
}
|
|
125
|
+
if (!(ciphertext instanceof Uint8Array)) {
|
|
126
|
+
throw new Error('stream-frame: ciphertext must be a Uint8Array');
|
|
127
|
+
}
|
|
128
|
+
const out = new Uint8Array(LENGTH_PREFIX_BYTES + NONCE_BYTES + ciphertext.length);
|
|
129
|
+
new DataView(out.buffer).setUint32(0, ciphertext.length, false);
|
|
130
|
+
out.set(nonce, LENGTH_PREFIX_BYTES);
|
|
131
|
+
out.set(ciphertext, LENGTH_PREFIX_BYTES + NONCE_BYTES);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reassembles a raw byte stream into complete `{nonce, ciphertext}`
|
|
137
|
+
* chunks. See the module doc comment for the wire format and the
|
|
138
|
+
* efficiency note on the internal buffer strategy.
|
|
139
|
+
*/
|
|
140
|
+
export class ChunkAccumulator {
|
|
141
|
+
/** @type {Uint8Array} */
|
|
142
|
+
#buf = new Uint8Array(0);
|
|
143
|
+
/** @type {number} Read cursor -- bytes before this offset are already consumed. */
|
|
144
|
+
#start = 0;
|
|
145
|
+
/** @type {number} Write cursor -- bytes before this offset are valid buffered data. */
|
|
146
|
+
#end = 0;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Feed newly-read bytes in and pull out every complete chunk now
|
|
150
|
+
* available. Any trailing partial chunk stays buffered for the next
|
|
151
|
+
* `feed()` call.
|
|
152
|
+
*
|
|
153
|
+
* @param {Uint8Array} bytes
|
|
154
|
+
* @returns {Array<{nonce: Uint8Array, ciphertext: Uint8Array}>}
|
|
155
|
+
*/
|
|
156
|
+
feed(bytes) {
|
|
157
|
+
if (bytes && bytes.length > 0) {
|
|
158
|
+
this.#append(bytes);
|
|
159
|
+
}
|
|
160
|
+
const chunks = [];
|
|
161
|
+
for (;;) {
|
|
162
|
+
const chunk = this.#tryParseOne();
|
|
163
|
+
if (chunk === null) break;
|
|
164
|
+
chunks.push(chunk);
|
|
165
|
+
}
|
|
166
|
+
// Once fully drained, reset cursors so the buffer doesn't retain a
|
|
167
|
+
// large-but-empty backing array indefinitely.
|
|
168
|
+
if (this.#start === this.#end) {
|
|
169
|
+
this.#start = 0;
|
|
170
|
+
this.#end = 0;
|
|
171
|
+
}
|
|
172
|
+
return chunks;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Call at clean stream EOF. Throws `StreamTornChunkError` if partial
|
|
177
|
+
* chunk bytes are still buffered (a truncated stream); otherwise a
|
|
178
|
+
* no-op.
|
|
179
|
+
*/
|
|
180
|
+
finish() {
|
|
181
|
+
const remaining = this.#end - this.#start;
|
|
182
|
+
if (remaining > 0) {
|
|
183
|
+
throw new StreamTornChunkError(
|
|
184
|
+
`stream-frame: stream ended with ${remaining} torn (incomplete) chunk bytes buffered`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @private */
|
|
190
|
+
#append(bytes) {
|
|
191
|
+
const liveLength = this.#end - this.#start;
|
|
192
|
+
const needed = liveLength + bytes.length;
|
|
193
|
+
if (needed > this.#buf.length - this.#start) {
|
|
194
|
+
// Grow (and compact away already-consumed prefix bytes) with
|
|
195
|
+
// amortized doubling, so repeated small feed() calls stay linear
|
|
196
|
+
// overall rather than re-copying the whole live region every time.
|
|
197
|
+
const newCapacity = Math.max(needed, this.#buf.length * 2, 4096);
|
|
198
|
+
const newBuf = new Uint8Array(newCapacity);
|
|
199
|
+
newBuf.set(this.#buf.subarray(this.#start, this.#end), 0);
|
|
200
|
+
this.#buf = newBuf;
|
|
201
|
+
this.#end = liveLength;
|
|
202
|
+
this.#start = 0;
|
|
203
|
+
}
|
|
204
|
+
this.#buf.set(bytes, this.#end);
|
|
205
|
+
this.#end += bytes.length;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** @private */
|
|
209
|
+
#tryParseOne() {
|
|
210
|
+
const available = this.#end - this.#start;
|
|
211
|
+
if (available < LENGTH_PREFIX_BYTES + NONCE_BYTES) return null;
|
|
212
|
+
const headerView = new DataView(this.#buf.buffer, this.#buf.byteOffset + this.#start, LENGTH_PREFIX_BYTES);
|
|
213
|
+
const n = headerView.getUint32(0, false);
|
|
214
|
+
if (n > MAX_WIRE_CIPHERTEXT_BYTES) {
|
|
215
|
+
throw new StreamTornChunkError(
|
|
216
|
+
`stream-frame: chunk length ${n} exceeds max ${MAX_WIRE_CIPHERTEXT_BYTES} -- corrupt or malicious framing`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
const total = LENGTH_PREFIX_BYTES + NONCE_BYTES + n;
|
|
220
|
+
if (available < total) return null; // wait for more bytes
|
|
221
|
+
const nonceStart = this.#start + LENGTH_PREFIX_BYTES;
|
|
222
|
+
const ciphertextStart = nonceStart + NONCE_BYTES;
|
|
223
|
+
const nonce = this.#buf.slice(nonceStart, ciphertextStart);
|
|
224
|
+
const ciphertext = this.#buf.slice(ciphertextStart, this.#start + total);
|
|
225
|
+
this.#start += total;
|
|
226
|
+
return { nonce, ciphertext };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Write coalescing ─────────────────────────────────────────────────
|
|
231
|
+
//
|
|
232
|
+
// Purely local, sender-side batching of small consecutive write() calls
|
|
233
|
+
// into one sealed chunk -- NOT a wire protocol change (the receiver has
|
|
234
|
+
// no idea how many writes got merged, see wsh #22's design-doc update).
|
|
235
|
+
// Smart defaults derive from session `type` (already known at
|
|
236
|
+
// `openSession()` time); callers can override via `enableE2E(key, {
|
|
237
|
+
// coalesce })` or fully disable via `coalesce: false`.
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Default coalescing profiles by session kind.
|
|
241
|
+
* - `pty` (interactive): latency-first -- flush on a short timer or a
|
|
242
|
+
* small byte threshold, whichever fires first. This isn't "batching
|
|
243
|
+
* keystrokes"; it mainly catches writes issued in the same event-loop
|
|
244
|
+
* tick (multi-byte UTF-8, escape sequences, paste bursts) -- a single
|
|
245
|
+
* keystroke still goes out almost immediately.
|
|
246
|
+
* - `exec` (bulk): throughput-first -- longer timer and the full 16 KiB
|
|
247
|
+
* soft target from the chunk-size design above.
|
|
248
|
+
* @type {Record<'pty'|'exec', {maxBytes: number, maxDelayMs: number}>}
|
|
249
|
+
*/
|
|
250
|
+
export const DEFAULT_COALESCE_PROFILES = Object.freeze({
|
|
251
|
+
pty: Object.freeze({ maxBytes: 4 * 1024, maxDelayMs: 6 }),
|
|
252
|
+
exec: Object.freeze({ maxBytes: CHUNK_SOFT_TARGET_BYTES, maxDelayMs: 30 }),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Resolve the effective coalescing config for a session.
|
|
257
|
+
*
|
|
258
|
+
* @param {'pty'|'exec'} kind
|
|
259
|
+
* @param {false|{maxBytes?: number, maxDelayMs?: number}|undefined} override
|
|
260
|
+
* @returns {{maxBytes: number, maxDelayMs: number}|null} `null` means "coalescing disabled -- seal every write immediately"
|
|
261
|
+
*/
|
|
262
|
+
export function resolveCoalesceOptions(kind, override) {
|
|
263
|
+
if (override === false) return null;
|
|
264
|
+
const base = DEFAULT_COALESCE_PROFILES[kind] || DEFAULT_COALESCE_PROFILES.exec;
|
|
265
|
+
if (override && typeof override === 'object') {
|
|
266
|
+
const maxBytes = Number.isFinite(override.maxBytes) && override.maxBytes > 0 ? override.maxBytes : base.maxBytes;
|
|
267
|
+
const maxDelayMs = Number.isFinite(override.maxDelayMs) && override.maxDelayMs >= 0 ? override.maxDelayMs : base.maxDelayMs;
|
|
268
|
+
return { maxBytes, maxDelayMs };
|
|
269
|
+
}
|
|
270
|
+
return { ...base };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Batches consecutive `write()` calls and flushes them as one chunk once
|
|
275
|
+
* `maxBytes` is buffered or `maxDelayMs` has elapsed since the first
|
|
276
|
+
* buffered byte of the current batch, whichever comes first.
|
|
277
|
+
*
|
|
278
|
+
* `write()` resolves once bytes are queued, not once they're actually
|
|
279
|
+
* flushed -- coalescing is local batching, so "queued" is the caller-
|
|
280
|
+
* visible unit of success (matching wsh #22's resolved design). Flush
|
|
281
|
+
* errors surface on the promise returned by the *next* `write()`/
|
|
282
|
+
* `flush()` call that observes the same underlying flush chain.
|
|
283
|
+
*/
|
|
284
|
+
export class WriteCoalescer {
|
|
285
|
+
#maxBytes;
|
|
286
|
+
#maxDelayMs;
|
|
287
|
+
#onFlush;
|
|
288
|
+
#pending = [];
|
|
289
|
+
#pendingLength = 0;
|
|
290
|
+
#timer = null;
|
|
291
|
+
#chain = Promise.resolve();
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* @param {{maxBytes: number, maxDelayMs: number}} options
|
|
295
|
+
* @param {function(Uint8Array): Promise<void>} onFlush - called with the merged buffered bytes
|
|
296
|
+
*/
|
|
297
|
+
constructor({ maxBytes, maxDelayMs }, onFlush) {
|
|
298
|
+
if (!(maxBytes > 0)) {
|
|
299
|
+
throw new Error('stream-frame: WriteCoalescer requires a positive maxBytes');
|
|
300
|
+
}
|
|
301
|
+
if (!(maxDelayMs >= 0)) {
|
|
302
|
+
throw new Error('stream-frame: WriteCoalescer requires a non-negative maxDelayMs');
|
|
303
|
+
}
|
|
304
|
+
if (typeof onFlush !== 'function') {
|
|
305
|
+
throw new Error('stream-frame: WriteCoalescer requires an onFlush callback');
|
|
306
|
+
}
|
|
307
|
+
this.#maxBytes = maxBytes;
|
|
308
|
+
this.#maxDelayMs = maxDelayMs;
|
|
309
|
+
this.#onFlush = onFlush;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Buffer bytes for later coalesced sealing.
|
|
314
|
+
* @param {Uint8Array} bytes
|
|
315
|
+
* @returns {Promise<void>}
|
|
316
|
+
*/
|
|
317
|
+
write(bytes) {
|
|
318
|
+
this.#pending.push(bytes);
|
|
319
|
+
this.#pendingLength += bytes.length;
|
|
320
|
+
if (this.#pendingLength >= this.#maxBytes) {
|
|
321
|
+
return this.flush();
|
|
322
|
+
}
|
|
323
|
+
if (this.#timer === null) {
|
|
324
|
+
this.#timer = setTimeout(() => {
|
|
325
|
+
this.#timer = null;
|
|
326
|
+
this.flush().catch(() => {
|
|
327
|
+
// Errors are observed via #chain by the next write()/flush()
|
|
328
|
+
// caller; swallow here so an unobserved timer-driven flush
|
|
329
|
+
// doesn't produce an unhandled rejection.
|
|
330
|
+
});
|
|
331
|
+
}, this.#maxDelayMs);
|
|
332
|
+
if (typeof this.#timer.unref === 'function') this.#timer.unref();
|
|
333
|
+
}
|
|
334
|
+
return this.#chain;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Force-flush any currently-buffered bytes immediately (used on
|
|
339
|
+
* session close and by the byte/timer thresholds above).
|
|
340
|
+
* @returns {Promise<void>}
|
|
341
|
+
*/
|
|
342
|
+
flush() {
|
|
343
|
+
if (this.#timer !== null) {
|
|
344
|
+
clearTimeout(this.#timer);
|
|
345
|
+
this.#timer = null;
|
|
346
|
+
}
|
|
347
|
+
if (this.#pending.length === 0) {
|
|
348
|
+
return this.#chain;
|
|
349
|
+
}
|
|
350
|
+
const merged = concatBytes(this.#pending, this.#pendingLength);
|
|
351
|
+
this.#pending = [];
|
|
352
|
+
this.#pendingLength = 0;
|
|
353
|
+
this.#chain = this.#chain.then(() => this.#onFlush(merged));
|
|
354
|
+
return this.#chain;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** @private */
|
|
359
|
+
function concatBytes(parts, totalLength) {
|
|
360
|
+
if (parts.length === 1) return parts[0];
|
|
361
|
+
const out = new Uint8Array(totalLength);
|
|
362
|
+
let offset = 0;
|
|
363
|
+
for (const part of parts) {
|
|
364
|
+
out.set(part, offset);
|
|
365
|
+
offset += part.length;
|
|
366
|
+
}
|
|
367
|
+
return out;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Re-exported for convenience so callers that only need stream-mode
|
|
371
|
+
// sealing don't have to import from two modules.
|
|
372
|
+
export { sealFrame };
|