@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.
- package/README.md +67 -9
- package/package.json +2 -2
- package/src/client.mjs +86 -43
- package/src/e2e-frame.mjs +177 -0
- package/src/index.d.ts +529 -11
- package/src/messages.gen.mjs +9 -10
- package/src/session.mjs +164 -1
- package/src/virtual-session.mjs +20 -1
package/src/messages.gen.mjs
CHANGED
|
@@ -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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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,
|
|
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(
|
|
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;
|
package/src/virtual-session.mjs
CHANGED
|
@@ -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() };
|