@johnhenry/wsh 0.13.0 → 0.15.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.
@@ -247,14 +247,11 @@ export function open({ kind, command, cols, rows, env } = {}) {
247
247
  return msg;
248
248
  }
249
249
 
250
- export function openOk({ channelId, streamIds = [], dataMode = "stream", capabilities = [] } = {}) {
251
- return {
252
- type: MSG.OPEN_OK,
253
- channel_id: channelId,
254
- stream_ids: streamIds,
255
- data_mode: dataMode,
256
- capabilities,
257
- };
250
+ export function openOk({ channelId, streamIds = [], dataMode = "stream", capabilities = [], sessionId, token } = {}) {
251
+ const msg = { type: MSG.OPEN_OK, channel_id: channelId, stream_ids: streamIds, data_mode: dataMode, capabilities };
252
+ if (sessionId !== undefined) msg.session_id = sessionId;
253
+ if (token !== undefined) msg.token = token;
254
+ return msg;
258
255
  }
259
256
 
260
257
  export function openFail({ reason } = {}) {
@@ -327,7 +324,8 @@ export function pong({ id } = {}) {
327
324
  }
328
325
 
329
326
  export function attach({ sessionId, token, mode = "control", deviceLabel } = {}) {
330
- const msg = { type: MSG.ATTACH, session_id: sessionId, token, mode };
327
+ const msg = { type: MSG.ATTACH, session_id: sessionId, mode };
328
+ if (token !== undefined) msg.token = token;
331
329
  if (deviceLabel !== undefined) msg.device_label = deviceLabel;
332
330
  return msg;
333
331
  }
@@ -779,9 +777,10 @@ export function keyExchange({ algorithm, publicKey, sessionId, kemPublicKey, kem
779
777
  return msg;
780
778
  }
781
779
 
782
- export function encryptedFrame({ nonce, ciphertext, sessionId } = {}) {
780
+ export function encryptedFrame({ channelId, nonce, ciphertext, sessionId } = {}) {
783
781
  return {
784
782
  type: MSG.ENCRYPTED_FRAME,
783
+ channel_id: channelId,
785
784
  nonce,
786
785
  ciphertext,
787
786
  session_id: sessionId,
package/src/session.mjs CHANGED
@@ -9,6 +9,7 @@ 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';
12
13
 
13
14
  // ── Session states ────────────────────────────────────────────────────
14
15
 
@@ -44,6 +45,23 @@ export class WshSession {
44
45
  /** @type {string[]} Advertised session capabilities. */
45
46
  #capabilities = [];
46
47
 
48
+ /**
49
+ * The server-assigned session_id this channel belongs to, from OPEN_OK
50
+ * (clawser #48). `undefined` for channel kinds with no Attach/Resume-able
51
+ * session (e.g. file channels).
52
+ * @type {string|undefined}
53
+ */
54
+ #sessionId;
55
+
56
+ /**
57
+ * The session-scoped HMAC token minted by the server at Open time
58
+ * (clawser #48), also from OPEN_OK. Pass it to `WshClient.resumeSession`
59
+ * (or optionally `attachSession`) from a later connection to reclaim
60
+ * this exact session. `undefined` alongside `#sessionId`.
61
+ * @type {Uint8Array|undefined}
62
+ */
63
+ #resumeToken;
64
+
47
65
  /**
48
66
  * Stream IDs returned by the server in OPEN_OK.
49
67
  * Typically { stdin: number, stdout: number } or a single bidirectional ID.
@@ -60,6 +78,27 @@ export class WshSession {
60
78
  /** @type {WshVirtualSessionBackend|null} */
61
79
  #virtualBackend = null;
62
80
 
81
+ /**
82
+ * Non-extractable AES-256-GCM CryptoKey set by `enableE2E()`, or `null`
83
+ * if E2E is not enabled for this session. Connection-scoped -- see
84
+ * `enableE2E`'s doc comment for why this must never be reused across a
85
+ * Resume/Attach.
86
+ * @type {CryptoKey|null}
87
+ */
88
+ #e2eKey = null;
89
+
90
+ /** @type {Uint8Array|null} This side's 4-byte nonce role tag, set by `enableE2E()`. */
91
+ #e2eSendRoleTag = null;
92
+
93
+ /** @type {Uint8Array|null} The peer's 4-byte nonce role tag, set by `enableE2E()`. */
94
+ #e2eRecvRoleTag = null;
95
+
96
+ /** @type {number} Next outgoing frame counter for this side. */
97
+ #e2eSendCounter = 0;
98
+
99
+ /** @type {number} Next expected incoming frame counter from the peer. */
100
+ #e2eRecvCounter = 0;
101
+
63
102
  /**
64
103
  * The readable side of the stdout data stream.
65
104
  * @type {ReadableStream<Uint8Array>|null}
@@ -151,8 +190,13 @@ export class WshSession {
151
190
  * @param {object} [opts]
152
191
  * @param {'stream'|'virtual'} [opts.dataMode='stream']
153
192
  * @param {string[]} [opts.capabilities=[]]
193
+ * @param {string} [opts.sessionId] - Server session_id from OPEN_OK (clawser #48; pty/exec only)
194
+ * @param {Uint8Array} [opts.resumeToken] - Session-scoped resume token from OPEN_OK (clawser #48)
154
195
  */
155
- constructor(transport, channelId, streamIds, kind, { dataMode = 'stream', capabilities = [] } = {}) {
196
+ constructor(
197
+ transport, channelId, streamIds, kind,
198
+ { dataMode = 'stream', capabilities = [], sessionId, resumeToken } = {}
199
+ ) {
156
200
  this.#transport = transport;
157
201
  this.channelId = channelId;
158
202
  this.#streamIds = streamIds;
@@ -160,6 +204,28 @@ export class WshSession {
160
204
  this.id = String(channelId);
161
205
  this.#dataMode = dataMode === 'virtual' ? 'virtual' : 'stream';
162
206
  this.#capabilities = Array.isArray(capabilities) ? [...capabilities] : [];
207
+ this.#sessionId = sessionId;
208
+ this.#resumeToken = resumeToken;
209
+ }
210
+
211
+ /**
212
+ * The server-assigned session_id this channel belongs to, if any
213
+ * (clawser #48; `undefined` for channel kinds with no Attach/Resume-able
214
+ * session, e.g. file channels). Pass to `WshClient.attachSession`/
215
+ * `resumeSession` from another connection.
216
+ */
217
+ get sessionId() {
218
+ return this.#sessionId;
219
+ }
220
+
221
+ /**
222
+ * The session-scoped resume token minted at Open time, if any (clawser
223
+ * #48). Pass it to `WshClient.resumeSession` from a later connection to
224
+ * reclaim this exact session, proving you're the same credentialed
225
+ * opener rather than merely an authorized principal.
226
+ */
227
+ get resumeToken() {
228
+ return this.#resumeToken;
163
229
  }
164
230
 
165
231
  /** Current session state. */
@@ -248,6 +314,60 @@ export class WshSession {
248
314
  this.#state = STATE_ACTIVE;
249
315
  }
250
316
 
317
+ /** Whether `enableE2E()` has been called and E2E sealing is active for this session. */
318
+ get e2eEnabled() {
319
+ return this.#e2eKey !== null;
320
+ }
321
+
322
+ /**
323
+ * Opt in to end-to-end encryption for this session's data plane
324
+ * (virtual-mode sessions only in this PR; stream-mode is out of
325
+ * scope -- see `e2e-frame.mjs`'s doc comment). After this call,
326
+ * `write()` seals outgoing data into `EncryptedFrame` messages instead
327
+ * of plaintext `SessionData`, and incoming `EncryptedFrame` messages
328
+ * are opened and delivered via `onData` exactly like `SessionData` is
329
+ * today; plaintext `SessionData` from a peer is no longer expected
330
+ * once this side has opted in (it's simply passed through unchanged,
331
+ * as before -- this method doesn't add any negotiation-failure
332
+ * handling; that's a later PR).
333
+ *
334
+ * IMPORTANT -- key lifetime: `sharedSecret` must come from a *fresh*
335
+ * `WshClient.initiateE2E()` call on the *current* connection. E2E
336
+ * state here is connection-scoped, not session-scoped: if this
337
+ * session is later detached and Resumed/Attached on a new connection,
338
+ * callers MUST run `initiateE2E()` again and call `enableE2E()` again
339
+ * with the new key -- never persist or reuse a `sharedSecret` (or its
340
+ * counters) across a resume. Calling `enableE2E()` again on an
341
+ * already-enabled session is fine and resets counters cleanly (a
342
+ * fresh key naturally means fresh counters), but the caller is
343
+ * responsible for actually supplying a fresh key when doing so.
344
+ *
345
+ * @param {CryptoKey} sharedSecret - AES-256-GCM CryptoKey from `WshClient.initiateE2E()`
346
+ * @param {object} opts
347
+ * @param {'initiator'|'responder'} opts.role - which side of the KeyExchange this session was;
348
+ * determines this side's nonce role tag (see `e2e-frame.mjs`'s `ROLE_TAGS`). The two peers of
349
+ * one session MUST pick opposite roles, or their nonces can collide.
350
+ */
351
+ enableE2E(sharedSecret, { role } = {}) {
352
+ if (!sharedSecret || typeof sharedSecret !== 'object') {
353
+ throw new Error('enableE2E: sharedSecret must be a CryptoKey (see WshClient.initiateE2E)');
354
+ }
355
+ if (role !== 'initiator' && role !== 'responder') {
356
+ throw new Error("enableE2E: opts.role must be 'initiator' or 'responder'");
357
+ }
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
+ if (!this.#sessionId) {
362
+ throw new Error('enableE2E: session has no server-assigned session_id to bind as AAD -- was OPEN_OK missing session_id?');
363
+ }
364
+ this.#e2eKey = sharedSecret;
365
+ this.#e2eSendRoleTag = ROLE_TAGS[role];
366
+ this.#e2eRecvRoleTag = role === 'initiator' ? ROLE_TAGS.responder : ROLE_TAGS.initiator;
367
+ this.#e2eSendCounter = 0;
368
+ this.#e2eRecvCounter = 0;
369
+ }
370
+
251
371
  // ── Public API ──────────────────────────────────────────────────────
252
372
 
253
373
  /**
@@ -264,6 +384,15 @@ export class WshSession {
264
384
  if (this.#virtualBackend === null) {
265
385
  throw new Error('Session not yet activated — virtual backend unavailable');
266
386
  }
387
+ if (this.#e2eKey !== null) {
388
+ const bytes = typeof data === 'string' ? textEncoder.encode(data) : data;
389
+ const counter = this.#e2eSendCounter++;
390
+ const { nonce, ciphertext } = await sealFrame(
391
+ this.#e2eKey, this.#sessionId, this.#e2eSendRoleTag, counter, bytes
392
+ );
393
+ await this.#virtualBackend.writeEncrypted({ nonce, ciphertext, sessionId: this.#sessionId });
394
+ return;
395
+ }
267
396
  await this.#virtualBackend.write(data);
268
397
  return;
269
398
  }
@@ -393,6 +522,40 @@ export class WshSession {
393
522
  break;
394
523
  }
395
524
 
525
+ case MSG.ENCRYPTED_FRAME: {
526
+ if (this.#e2eKey === null) {
527
+ console.error('[wsh:session] received EncryptedFrame but E2E is not enabled on this session — dropping');
528
+ break;
529
+ }
530
+ if (msg.session_id !== this.#sessionId) {
531
+ console.error('[wsh:session] EncryptedFrame session_id mismatch — dropping (possible splice attempt)');
532
+ break;
533
+ }
534
+ // openFrame is async; _handleControlMessage is a synchronous
535
+ // dispatch entry point (see the SESSION_DATA case above and its
536
+ // callers), so this fires the decrypt-and-deliver as a
537
+ // background task rather than awaiting it here. Per-channel
538
+ // control messages are already dispatched serially by the
539
+ // caller (see transport.mjs's dispatchSerially/SerialQueue), so
540
+ // frames still arrive at openFrame() in wire order; only the
541
+ // delivery of *this* frame's plaintext to onData is deferred by
542
+ // one microtask/macrotask relative to synchronous cases.
543
+ const counter = this.#e2eRecvCounter++;
544
+ openFrame(this.#e2eKey, this.#sessionId, counter, { nonce: msg.nonce, ciphertext: msg.ciphertext })
545
+ .then((plaintext) => {
546
+ this.#virtualBackend?.pushData(plaintext);
547
+ try {
548
+ this.onData?.(plaintext);
549
+ } catch (err) {
550
+ console.error('[wsh:session] onData handler error:', err);
551
+ }
552
+ })
553
+ .catch((err) => {
554
+ console.error('[wsh:session] failed to open EncryptedFrame:', err);
555
+ });
556
+ break;
557
+ }
558
+
396
559
  case MSG.FILE_CHUNK: {
397
560
  if (this.#fileChunkWaiter) {
398
561
  const { resolve } = this.#fileChunkWaiter;
@@ -1,4 +1,4 @@
1
- import { sessionData as sessionDataMsg } from './messages.mjs';
1
+ import { sessionData as sessionDataMsg, encryptedFrame as encryptedFrameMsg } from './messages.mjs';
2
2
 
3
3
  const textEncoder = new TextEncoder();
4
4
 
@@ -65,6 +65,25 @@ export class WshVirtualSessionBackend {
65
65
  );
66
66
  }
67
67
 
68
+ /**
69
+ * Send a pre-sealed EncryptedFrame instead of plaintext SessionData.
70
+ * Used by `WshSession.write()` when E2E is enabled for this session
71
+ * (see `session.mjs`'s `enableE2E`).
72
+ *
73
+ * @param {{nonce: Uint8Array, ciphertext: Uint8Array, sessionId: string}} frame
74
+ * @returns {Promise<void>}
75
+ */
76
+ async writeEncrypted({ nonce, ciphertext, sessionId }) {
77
+ await this.#sendControl(
78
+ encryptedFrameMsg({
79
+ channelId: this.#channelId,
80
+ nonce,
81
+ ciphertext,
82
+ sessionId,
83
+ })
84
+ );
85
+ }
86
+
68
87
  async read() {
69
88
  if (this.#readQueue.length > 0) {
70
89
  return { done: false, value: this.#readQueue.shift() };