@johnhenry/wsh 0.14.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/e2e-frame.mjs +177 -0
- package/src/index.d.ts +483 -9
- package/src/messages.gen.mjs +2 -1
- package/src/session.mjs +119 -0
- package/src/virtual-session.mjs +20 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# wsh
|
|
1
|
+
# @johnhenry/wsh
|
|
2
2
|
|
|
3
3
|
Full documentation: [opensource.johnhenry.me/wsh](https://opensource.johnhenry.me/wsh/)
|
|
4
4
|
|
|
@@ -8,7 +8,7 @@ Full documentation: [opensource.johnhenry.me/wsh](https://opensource.johnhenry.m
|
|
|
8
8
|
|
|
9
9
|
Browser-native remote command execution over WebTransport/WebSocket with Ed25519 authentication.
|
|
10
10
|
|
|
11
|
-
wsh
|
|
11
|
+
wsh is a pure-JS client library that connects browsers to remote shells. It implements its own binary protocol — CBOR messages over QMux-multiplexed WebSocket or native WebTransport streams — with Ed25519 challenge-response auth, session management, and MCP tool bridging.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -26,16 +26,28 @@ Or via CDN:
|
|
|
26
26
|
|
|
27
27
|
## Features
|
|
28
28
|
|
|
29
|
-
- **Ed25519 authentication** -- challenge-response via Web Crypto API, SSH key format support
|
|
30
|
-
- **Dual transport** -- WebTransport (native streams) and WebSocket (multiplexed
|
|
29
|
+
- **Ed25519 authentication** -- challenge-response via Web Crypto API with a transcript binding username and session id, SSH key format support
|
|
30
|
+
- **Dual transport** -- WebTransport (native streams) and WebSocket (QMux-multiplexed streams) with identical API
|
|
31
31
|
- **CBOR encoding** -- compact binary wire format with length-prefixed framing
|
|
32
|
-
- **Session management** -- open, attach, resume, detach, rename PTY/exec sessions
|
|
33
|
-
- **Reverse mode** -- register as a peer and accept incoming connections through a relay
|
|
34
|
-
- **File transfer** -- scp-like upload/download
|
|
32
|
+
- **Session management** -- open, attach, resume, detach, rename PTY/exec sessions, with session-scoped resume tokens and per-principal access grants
|
|
33
|
+
- **Reverse mode** -- register as a peer (via a signed peer record) and accept incoming connections through a relay
|
|
34
|
+
- **File transfer** -- scp-like upload/download as `FileChunk` control messages in 64KB chunks
|
|
35
35
|
- **MCP bridge** -- discover and invoke remote MCP tools through the control channel
|
|
36
36
|
- **Session recording** -- asciicast v2 compatible recording and playback with seek/pause/resume
|
|
37
37
|
- **Key management** -- IndexedDB storage with OPFS encrypted backup (PBKDF2 + AES-256-GCM)
|
|
38
|
-
- **
|
|
38
|
+
- **95 message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, and more
|
|
39
|
+
|
|
40
|
+
## Wire Protocol: QMux
|
|
41
|
+
|
|
42
|
+
Over WebSocket, wsh multiplexes streams with QMux (draft-ietf-quic-qmux-02):
|
|
43
|
+
QUIC-v1 frames -- RFC 9000 varints, STREAM, RESET_STREAM, STOP_SENDING,
|
|
44
|
+
MAX_DATA/MAX_STREAM_DATA/MAX_STREAMS, CONNECTION_CLOSE, DATAGRAM, plus
|
|
45
|
+
RESET_STREAM_AT from draft-ietf-quic-reliable-stream-reset -- carried in
|
|
46
|
+
self-delimiting Records over the ordered, reliable WebSocket byte stream.
|
|
47
|
+
The control channel is QMux stream 0, and every stream gets QUIC-style
|
|
48
|
+
windowed flow control, so a slow consumer exerts real backpressure.
|
|
49
|
+
`QMuxConnection` and the frame codec primitives are exported so alternate
|
|
50
|
+
server implementations can speak the same framing.
|
|
39
51
|
|
|
40
52
|
## Quick Start
|
|
41
53
|
|
|
@@ -94,6 +106,27 @@ console.log(new TextDecoder().decode(stdout));
|
|
|
94
106
|
console.log('Exit code:', exitCode);
|
|
95
107
|
```
|
|
96
108
|
|
|
109
|
+
## Attach and Resume
|
|
110
|
+
|
|
111
|
+
Opening a PTY/exec session returns a session-scoped credential alongside the channel:
|
|
112
|
+
|
|
113
|
+
```js
|
|
114
|
+
session.sessionId; // server-assigned session id (undefined for e.g. file channels)
|
|
115
|
+
session.resumeToken; // token minted at open time; only the opener receives it
|
|
116
|
+
|
|
117
|
+
// The original opener, reclaiming its session from a fresh connection:
|
|
118
|
+
await client.resumeSession(session.sessionId, session.resumeToken);
|
|
119
|
+
|
|
120
|
+
// Any other authorized principal attaches without a token -- ownership
|
|
121
|
+
// or an ACL grant is enough:
|
|
122
|
+
await client.grantSessionAccess(session.sessionId, 'bob'); // by the owner
|
|
123
|
+
await otherClient.attachSession(session.sessionId); // by 'bob'
|
|
124
|
+
|
|
125
|
+
// Other session-management round trips:
|
|
126
|
+
await client.detach(session.sessionId); // leave it running server-side
|
|
127
|
+
await client.listRemoteSessions(); // sessions this key can see
|
|
128
|
+
```
|
|
129
|
+
|
|
97
130
|
## API Overview
|
|
98
131
|
|
|
99
132
|
### Core Classes
|
|
@@ -117,17 +150,19 @@ console.log('Exit code:', exitCode);
|
|
|
117
150
|
| `SessionPlayer` | Replay recordings with original timing |
|
|
118
151
|
| `generateKeyPair()` | Create Ed25519 key pair via Web Crypto |
|
|
119
152
|
| `signChallenge()` | Build transcript + sign for auth handshake |
|
|
153
|
+
| `signPeerRecord()` / `verifyPeerRecord()` | Sign / verify reverse-mode peer records |
|
|
120
154
|
| `fingerprint()` | SHA-256 hex fingerprint of a public key |
|
|
121
155
|
|
|
122
156
|
### Protocol
|
|
123
157
|
|
|
124
158
|
| Export | Description |
|
|
125
159
|
|--------|-------------|
|
|
126
|
-
| `MSG` |
|
|
160
|
+
| `MSG` | 95 message type constants (hex opcodes) |
|
|
127
161
|
| `CHANNEL_KIND` | Channel types: `pty`, `exec`, `meta`, `file`, `tcp`, `udp`, `job` |
|
|
128
162
|
| `AUTH_METHOD` | Auth methods: `pubkey`, `password` |
|
|
129
163
|
| `cborEncode` / `cborDecode` | CBOR codec (maps, arrays, strings, ints, bytes, bools, null, floats) |
|
|
130
164
|
| `frameEncode` / `FrameDecoder` | 4-byte big-endian length-prefixed framing |
|
|
165
|
+
| `QMuxConnection` + QMux primitives | QMux stream state machine, error codes, and stream-id helpers for building alternate servers |
|
|
131
166
|
|
|
132
167
|
## Protocol Specification
|
|
133
168
|
|
|
@@ -137,6 +172,25 @@ The `spec/` directory contains the protocol definition:
|
|
|
137
172
|
- `wsh-v1.md` -- human-readable protocol specification
|
|
138
173
|
- `codegen.mjs` -- generates `messages.gen.mjs` from the YAML spec
|
|
139
174
|
|
|
175
|
+
## Security
|
|
176
|
+
|
|
177
|
+
- **Auth transcript binding** -- challenge signatures cover
|
|
178
|
+
`SHA-256("wsh-v1\0" || lp(username) || lp(session_id) || nonce || channel_binding)`,
|
|
179
|
+
so a signature can't be replayed against a different session or relabeled
|
|
180
|
+
to a different username.
|
|
181
|
+
- **Signed peer records** -- reverse-mode registration is self-signed by the
|
|
182
|
+
peer's identity key (the libp2p RFC 0002/0003 pattern), in a signing
|
|
183
|
+
domain separate from the auth challenge. `listPeers()` verifies every
|
|
184
|
+
entry client-side and reports a `verified` boolean, independent of
|
|
185
|
+
trusting the relay.
|
|
186
|
+
- **Hybrid post-quantum E2E (experimental)** --
|
|
187
|
+
`initiateE2E(sessionId, 'X25519+ML-KEM-768')` combines X25519 ECDH with
|
|
188
|
+
ML-KEM-768 via HKDF-SHA256, preferring native WebCrypto ML-KEM-768 (Node
|
|
189
|
+
24.7+) with the optional `@noble/post-quantum` pure-JS fallback, and
|
|
190
|
+
falling back to classical X25519 automatically when the peer can't do
|
|
191
|
+
hybrid (check the returned `hybrid` flag). The derived AES-256-GCM key is
|
|
192
|
+
not yet wired to actual frame encryption.
|
|
193
|
+
|
|
140
194
|
## Browser Compatibility
|
|
141
195
|
|
|
142
196
|
Requires a browser (or Node.js 24+) with:
|
|
@@ -147,6 +201,10 @@ Requires a browser (or Node.js 24+) with:
|
|
|
147
201
|
- TextEncoder/TextDecoder
|
|
148
202
|
- ReadableStream/WritableStream
|
|
149
203
|
|
|
204
|
+
Hybrid ML-KEM-768 key exchange prefers native WebCrypto ML-KEM (Node 24.7+,
|
|
205
|
+
some browsers); elsewhere the optional `@noble/post-quantum` dependency is
|
|
206
|
+
loaded dynamically.
|
|
207
|
+
|
|
150
208
|
## License
|
|
151
209
|
|
|
152
210
|
MIT
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/wsh",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Web Shell
|
|
3
|
+
"version": "0.15.0",
|
|
4
|
+
"description": "Web Shell — browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
7
7
|
"exports": {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EncryptedFrame sealing/opening for wsh's opt-in end-to-end encryption
|
|
3
|
+
* layer (see `client.mjs`'s `initiateE2E`, which derives the AES-256-GCM
|
|
4
|
+
* `CryptoKey` this module seals/opens with, and `spec/wsh-v1.yaml`'s
|
|
5
|
+
* `EncryptedFrame` message).
|
|
6
|
+
*
|
|
7
|
+
* This is PR 1 of a multi-PR E2E rollout: it implements the AEAD
|
|
8
|
+
* primitives and wires them into virtual-mode sessions only (see
|
|
9
|
+
* `session.mjs`'s `enableE2E`). Stream-mode sessions (raw WebTransport
|
|
10
|
+
* byte streams with no message boundaries) are explicitly out of scope
|
|
11
|
+
* here -- they need separate ad hoc chunk-framing design.
|
|
12
|
+
*
|
|
13
|
+
* ── Nonce construction ─────────────────────────────────────────────
|
|
14
|
+
* Nonces are the AES-GCM-mandated 96 bits (12 bytes), built as:
|
|
15
|
+
*
|
|
16
|
+
* [ 8-byte big-endian monotonic counter ][ 4-byte sender-role tag ]
|
|
17
|
+
*
|
|
18
|
+
* The counter starts at 0 and increments by one for every frame a given
|
|
19
|
+
* sender seals (never reused, per `openFrame`'s strict expected-counter
|
|
20
|
+
* check below). The 4-byte role tag is fixed per sender for the
|
|
21
|
+
* lifetime of one E2E-enabled connection (see `session.mjs`'s
|
|
22
|
+
* `enableE2E({ role })`) and differs between the two peers of a
|
|
23
|
+
* session, so the two directions of one session structurally cannot
|
|
24
|
+
* produce a colliding nonce even under fully concurrent bidirectional
|
|
25
|
+
* traffic -- the 8-byte counter alone would collide if both sides ever
|
|
26
|
+
* reached the same count, but the two peers' tags never match, so the
|
|
27
|
+
* full 12-byte nonces never match either.
|
|
28
|
+
*
|
|
29
|
+
* ── AAD ────────────────────────────────────────────────────────────
|
|
30
|
+
* The frame's `session_id` (UTF-8 bytes) is bound as AES-GCM
|
|
31
|
+
* "additional authenticated data": it's authenticated but never
|
|
32
|
+
* encrypted, so ciphertext can't be spliced from one session onto
|
|
33
|
+
* another by a relay that only ever sees ciphertext (the server, for a
|
|
34
|
+
* genuinely E2E session) -- decryption fails if the session_id supplied
|
|
35
|
+
* to `openFrame` doesn't match the one the ciphertext was sealed under.
|
|
36
|
+
*
|
|
37
|
+
* ── Replay/reorder protection ─────────────────────────────────────
|
|
38
|
+
* `openFrame` requires the frame's nonce counter to be *exactly* the
|
|
39
|
+
* caller-supplied `expectedCounter` -- not merely "not yet seen" or
|
|
40
|
+
* "not too old". This is intentionally strict for v1: any replayed,
|
|
41
|
+
* duplicated, dropped, or reordered frame is rejected outright rather
|
|
42
|
+
* than tolerated or reordered-and-delivered. A later PR can relax this
|
|
43
|
+
* to a sliding window if wsh ever needs to tolerate out-of-order
|
|
44
|
+
* delivery for E2E frames; today's virtual-mode transport already
|
|
45
|
+
* delivers control messages in order, so strict-next-counter is not a
|
|
46
|
+
* practical limitation.
|
|
47
|
+
*
|
|
48
|
+
* ── Key lifetime ───────────────────────────────────────────────────
|
|
49
|
+
* The `key` passed to `sealFrame`/`openFrame` is connection-scoped, not
|
|
50
|
+
* session-scoped: a session that is detached and later Resumed/Attached
|
|
51
|
+
* on a new connection MUST run a fresh `initiateE2E()` and call
|
|
52
|
+
* `enableE2E()` again with the new key. Never persist or reuse a
|
|
53
|
+
* `sharedSecret` (or its counters) across a resume -- see
|
|
54
|
+
* `session.mjs`'s `enableE2E` doc comment.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
const NONCE_LENGTH = 12;
|
|
58
|
+
const COUNTER_LENGTH = 8;
|
|
59
|
+
const ROLE_TAG_LENGTH = 4;
|
|
60
|
+
|
|
61
|
+
const textEncoder = new TextEncoder();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Encode a non-negative integer counter as an 8-byte big-endian value.
|
|
65
|
+
* @param {number} counter
|
|
66
|
+
* @returns {Uint8Array}
|
|
67
|
+
*/
|
|
68
|
+
function encodeCounter(counter) {
|
|
69
|
+
if (!Number.isInteger(counter) || counter < 0) {
|
|
70
|
+
throw new Error(`e2e-frame: counter must be a non-negative integer, got ${counter}`);
|
|
71
|
+
}
|
|
72
|
+
const bytes = new Uint8Array(COUNTER_LENGTH);
|
|
73
|
+
const view = new DataView(bytes.buffer);
|
|
74
|
+
// DataView.setBigUint64 covers the full 64-bit range; counters here
|
|
75
|
+
// never realistically approach it, but there's no reason to cap early.
|
|
76
|
+
view.setBigUint64(0, BigInt(counter), false);
|
|
77
|
+
return bytes;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the 12-byte nonce for a given (counter, role tag) pair. Exported
|
|
82
|
+
* mainly for tests -- callers should go through `sealFrame`/`openFrame`.
|
|
83
|
+
*
|
|
84
|
+
* @param {number} counter
|
|
85
|
+
* @param {Uint8Array} roleTag - exactly 4 bytes
|
|
86
|
+
* @returns {Uint8Array}
|
|
87
|
+
*/
|
|
88
|
+
export function buildNonce(counter, roleTag) {
|
|
89
|
+
if (!(roleTag instanceof Uint8Array) || roleTag.length !== ROLE_TAG_LENGTH) {
|
|
90
|
+
throw new Error(`e2e-frame: roleTag must be a ${ROLE_TAG_LENGTH}-byte Uint8Array`);
|
|
91
|
+
}
|
|
92
|
+
const nonce = new Uint8Array(NONCE_LENGTH);
|
|
93
|
+
nonce.set(encodeCounter(counter), 0);
|
|
94
|
+
nonce.set(roleTag, COUNTER_LENGTH);
|
|
95
|
+
return nonce;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Seal a plaintext frame under an AES-256-GCM key.
|
|
100
|
+
*
|
|
101
|
+
* @param {CryptoKey} key - non-extractable AES-GCM CryptoKey (e.g. from `client.mjs`'s `initiateE2E`)
|
|
102
|
+
* @param {string} sessionId - bound as AAD
|
|
103
|
+
* @param {Uint8Array} roleTag - 4-byte sender role tag, distinct between the two peers of a session
|
|
104
|
+
* @param {number} counter - this sender's next monotonic send counter (0, 1, 2, ...)
|
|
105
|
+
* @param {Uint8Array} plaintext
|
|
106
|
+
* @returns {Promise<{nonce: Uint8Array, ciphertext: Uint8Array}>}
|
|
107
|
+
*/
|
|
108
|
+
export async function sealFrame(key, sessionId, roleTag, counter, plaintext) {
|
|
109
|
+
if (!key || typeof key !== 'object') {
|
|
110
|
+
throw new Error('e2e-frame: sealFrame requires a CryptoKey');
|
|
111
|
+
}
|
|
112
|
+
const nonce = buildNonce(counter, roleTag);
|
|
113
|
+
const aad = textEncoder.encode(sessionId);
|
|
114
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt(
|
|
115
|
+
{ name: 'AES-GCM', iv: nonce, additionalData: aad },
|
|
116
|
+
key,
|
|
117
|
+
plaintext
|
|
118
|
+
));
|
|
119
|
+
return { nonce, ciphertext };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Open (decrypt + authenticate) a sealed frame.
|
|
124
|
+
*
|
|
125
|
+
* Throws if:
|
|
126
|
+
* - the AEAD authentication tag doesn't verify (tampered ciphertext,
|
|
127
|
+
* wrong key, or `sessionId` mismatch since it's bound as AAD), or
|
|
128
|
+
* - the frame's nonce counter isn't exactly `expectedCounter` (replay,
|
|
129
|
+
* reorder, or drop -- see this module's doc comment).
|
|
130
|
+
*
|
|
131
|
+
* @param {CryptoKey} key
|
|
132
|
+
* @param {string} sessionId - must match what the frame was sealed with (bound as AAD)
|
|
133
|
+
* @param {number} expectedCounter - the exact next counter this peer expects from the sender
|
|
134
|
+
* @param {{nonce: Uint8Array, ciphertext: Uint8Array}} frame
|
|
135
|
+
* @returns {Promise<Uint8Array>} plaintext
|
|
136
|
+
*/
|
|
137
|
+
export async function openFrame(key, sessionId, expectedCounter, { nonce, ciphertext }) {
|
|
138
|
+
if (!key || typeof key !== 'object') {
|
|
139
|
+
throw new Error('e2e-frame: openFrame requires a CryptoKey');
|
|
140
|
+
}
|
|
141
|
+
if (!(nonce instanceof Uint8Array) || nonce.length !== NONCE_LENGTH) {
|
|
142
|
+
throw new Error(`e2e-frame: nonce must be a ${NONCE_LENGTH}-byte Uint8Array`);
|
|
143
|
+
}
|
|
144
|
+
const actualCounter = Number(new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength).getBigUint64(0, false));
|
|
145
|
+
if (actualCounter !== expectedCounter) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`e2e-frame: replay/reorder detected -- expected counter ${expectedCounter}, got ${actualCounter}`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const aad = textEncoder.encode(sessionId);
|
|
151
|
+
try {
|
|
152
|
+
return new Uint8Array(await crypto.subtle.decrypt(
|
|
153
|
+
{ name: 'AES-GCM', iv: nonce, additionalData: aad },
|
|
154
|
+
key,
|
|
155
|
+
ciphertext
|
|
156
|
+
));
|
|
157
|
+
} catch (err) {
|
|
158
|
+
throw new Error(`e2e-frame: authentication failed while opening frame (${err.message})`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const E2E_NONCE_LENGTH = NONCE_LENGTH;
|
|
163
|
+
export const E2E_ROLE_TAG_LENGTH = ROLE_TAG_LENGTH;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Deterministic 4-byte role tags for the two sides of one E2E-enabled
|
|
167
|
+
* connection. Callers pick a role at `enableE2E()` time -- 'initiator'
|
|
168
|
+
* is naturally the side that called `initiateE2E()` first / sent round-1
|
|
169
|
+
* KeyExchange, 'responder' the other side; any consistent, non-
|
|
170
|
+
* overlapping assignment works since these are just fixed structural
|
|
171
|
+
* tags, not secret values.
|
|
172
|
+
* @type {Record<'initiator'|'responder', Uint8Array>}
|
|
173
|
+
*/
|
|
174
|
+
export const ROLE_TAGS = Object.freeze({
|
|
175
|
+
initiator: Uint8Array.from([0x69, 0x6e, 0x69, 0x74]), // "init"
|
|
176
|
+
responder: Uint8Array.from([0x72, 0x65, 0x73, 0x70]), // "resp"
|
|
177
|
+
});
|
package/src/index.d.ts
CHANGED
|
@@ -126,6 +126,7 @@ export const MSG: {
|
|
|
126
126
|
readonly REVERSE_CONNECT: 0x53;
|
|
127
127
|
readonly REVERSE_ACCEPT: 0x54;
|
|
128
128
|
readonly REVERSE_REJECT: 0x55;
|
|
129
|
+
readonly RELAY_FORWARD: 0x56;
|
|
129
130
|
|
|
130
131
|
// Gateway
|
|
131
132
|
readonly OPEN_TCP: 0x70;
|
|
@@ -249,6 +250,7 @@ export function serverHello(opts?: {
|
|
|
249
250
|
|
|
250
251
|
export function challenge(opts?: {
|
|
251
252
|
nonce?: Uint8Array;
|
|
253
|
+
sessionId?: string;
|
|
252
254
|
}): WshMessage;
|
|
253
255
|
|
|
254
256
|
export function authMethods(opts?: {
|
|
@@ -264,7 +266,7 @@ export function auth(opts?: {
|
|
|
264
266
|
|
|
265
267
|
export function authOk(opts?: {
|
|
266
268
|
sessionId?: string;
|
|
267
|
-
token?:
|
|
269
|
+
token?: Uint8Array;
|
|
268
270
|
ttl?: number;
|
|
269
271
|
}): WshMessage;
|
|
270
272
|
|
|
@@ -287,6 +289,8 @@ export function openOk(opts?: {
|
|
|
287
289
|
streamIds?: number[];
|
|
288
290
|
dataMode?: string;
|
|
289
291
|
capabilities?: string[];
|
|
292
|
+
sessionId?: string;
|
|
293
|
+
token?: Uint8Array;
|
|
290
294
|
}): WshMessage;
|
|
291
295
|
|
|
292
296
|
export function openFail(opts?: {
|
|
@@ -337,14 +341,14 @@ export function pong(opts?: {
|
|
|
337
341
|
|
|
338
342
|
export function attach(opts?: {
|
|
339
343
|
sessionId?: string;
|
|
340
|
-
token?:
|
|
344
|
+
token?: Uint8Array;
|
|
341
345
|
mode?: string;
|
|
342
346
|
deviceLabel?: string;
|
|
343
347
|
}): WshMessage;
|
|
344
348
|
|
|
345
349
|
export function resume(opts?: {
|
|
346
350
|
sessionId?: string;
|
|
347
|
-
token?:
|
|
351
|
+
token?: Uint8Array;
|
|
348
352
|
lastSeq?: number;
|
|
349
353
|
}): WshMessage;
|
|
350
354
|
|
|
@@ -410,6 +414,10 @@ export function reverseRegister(opts?: {
|
|
|
410
414
|
supportsEcho?: boolean;
|
|
411
415
|
supportsTermSync?: boolean;
|
|
412
416
|
publicKey?: Uint8Array | null;
|
|
417
|
+
/** The signing peer's own monotonic counter for its signed peer record. */
|
|
418
|
+
seq?: number;
|
|
419
|
+
/** Ed25519 signature over the peer record transcript (see signPeerRecord). */
|
|
420
|
+
recordSignature?: Uint8Array;
|
|
413
421
|
}): WshMessage;
|
|
414
422
|
|
|
415
423
|
export function reverseList(): WshMessage;
|
|
@@ -536,6 +544,262 @@ export function gatewayData(opts?: {
|
|
|
536
544
|
data?: Uint8Array;
|
|
537
545
|
}): WshMessage;
|
|
538
546
|
|
|
547
|
+
// -- Message constructors (session extras) --
|
|
548
|
+
|
|
549
|
+
export function clipboard(opts?: {
|
|
550
|
+
direction?: string;
|
|
551
|
+
data?: string;
|
|
552
|
+
}): WshMessage;
|
|
553
|
+
|
|
554
|
+
export function recordingExport(opts?: {
|
|
555
|
+
sessionId?: string;
|
|
556
|
+
format?: string;
|
|
557
|
+
data?: string;
|
|
558
|
+
}): WshMessage;
|
|
559
|
+
|
|
560
|
+
export function commandJournal(opts?: {
|
|
561
|
+
sessionId?: string;
|
|
562
|
+
command?: string;
|
|
563
|
+
exitCode?: number;
|
|
564
|
+
durationMs?: number;
|
|
565
|
+
cwd?: string;
|
|
566
|
+
timestamp?: number;
|
|
567
|
+
}): WshMessage;
|
|
568
|
+
|
|
569
|
+
export function metricsRequest(): WshMessage;
|
|
570
|
+
|
|
571
|
+
export function suspendSession(opts?: {
|
|
572
|
+
sessionId?: string;
|
|
573
|
+
action?: string;
|
|
574
|
+
}): WshMessage;
|
|
575
|
+
|
|
576
|
+
export function restartPty(opts?: {
|
|
577
|
+
sessionId?: string;
|
|
578
|
+
command?: string;
|
|
579
|
+
}): WshMessage;
|
|
580
|
+
|
|
581
|
+
export function sessionListRequest(): WshMessage;
|
|
582
|
+
|
|
583
|
+
export function sessionList(opts?: {
|
|
584
|
+
sessions?: unknown[];
|
|
585
|
+
}): WshMessage;
|
|
586
|
+
|
|
587
|
+
export function detach(opts?: {
|
|
588
|
+
sessionId?: string;
|
|
589
|
+
}): WshMessage;
|
|
590
|
+
|
|
591
|
+
export function detachOk(opts?: {
|
|
592
|
+
sessionId?: string;
|
|
593
|
+
}): WshMessage;
|
|
594
|
+
|
|
595
|
+
export function detachFail(opts?: {
|
|
596
|
+
reason?: string;
|
|
597
|
+
}): WshMessage;
|
|
598
|
+
|
|
599
|
+
// -- Message constructors (guest sharing) --
|
|
600
|
+
|
|
601
|
+
export function guestInvite(opts?: {
|
|
602
|
+
sessionId?: string;
|
|
603
|
+
ttl?: number;
|
|
604
|
+
permissions?: string[];
|
|
605
|
+
}): WshMessage;
|
|
606
|
+
|
|
607
|
+
export function guestJoin(opts?: {
|
|
608
|
+
token?: string;
|
|
609
|
+
deviceLabel?: string;
|
|
610
|
+
}): WshMessage;
|
|
611
|
+
|
|
612
|
+
export function guestRevoke(opts?: {
|
|
613
|
+
token?: string;
|
|
614
|
+
reason?: string;
|
|
615
|
+
}): WshMessage;
|
|
616
|
+
|
|
617
|
+
export function shareSession(opts?: {
|
|
618
|
+
sessionId?: string;
|
|
619
|
+
mode?: string;
|
|
620
|
+
ttl?: number;
|
|
621
|
+
}): WshMessage;
|
|
622
|
+
|
|
623
|
+
export function shareRevoke(opts?: {
|
|
624
|
+
shareId?: string;
|
|
625
|
+
reason?: string;
|
|
626
|
+
}): WshMessage;
|
|
627
|
+
|
|
628
|
+
// -- Message constructors (compression / rate control / linking) --
|
|
629
|
+
|
|
630
|
+
export function compressBegin(opts?: {
|
|
631
|
+
algorithm?: string;
|
|
632
|
+
level?: number;
|
|
633
|
+
}): WshMessage;
|
|
634
|
+
|
|
635
|
+
export function compressAck(opts?: {
|
|
636
|
+
algorithm?: string;
|
|
637
|
+
accepted?: boolean;
|
|
638
|
+
}): WshMessage;
|
|
639
|
+
|
|
640
|
+
export function rateControl(opts?: {
|
|
641
|
+
sessionId?: string;
|
|
642
|
+
maxBytesPerSec?: number;
|
|
643
|
+
policy?: string;
|
|
644
|
+
}): WshMessage;
|
|
645
|
+
|
|
646
|
+
export function rateWarning(opts?: {
|
|
647
|
+
sessionId?: string;
|
|
648
|
+
queuedBytes?: number;
|
|
649
|
+
action?: string;
|
|
650
|
+
}): WshMessage;
|
|
651
|
+
|
|
652
|
+
export function sessionLink(opts?: {
|
|
653
|
+
sourceSession?: string;
|
|
654
|
+
targetHost?: string;
|
|
655
|
+
targetPort?: number;
|
|
656
|
+
targetUser?: string;
|
|
657
|
+
}): WshMessage;
|
|
658
|
+
|
|
659
|
+
export function sessionUnlink(opts?: {
|
|
660
|
+
linkId?: string;
|
|
661
|
+
reason?: string;
|
|
662
|
+
}): WshMessage;
|
|
663
|
+
|
|
664
|
+
// -- Message constructors (copilot) --
|
|
665
|
+
|
|
666
|
+
export function copilotAttach(opts?: {
|
|
667
|
+
sessionId?: string;
|
|
668
|
+
model?: string;
|
|
669
|
+
contextWindow?: number;
|
|
670
|
+
}): WshMessage;
|
|
671
|
+
|
|
672
|
+
export function copilotSuggest(opts?: {
|
|
673
|
+
sessionId?: string;
|
|
674
|
+
suggestion?: string;
|
|
675
|
+
confidence?: number;
|
|
676
|
+
}): WshMessage;
|
|
677
|
+
|
|
678
|
+
export function copilotDetach(opts?: {
|
|
679
|
+
sessionId?: string;
|
|
680
|
+
reason?: string;
|
|
681
|
+
}): WshMessage;
|
|
682
|
+
|
|
683
|
+
// -- Message constructors (e2e / echo / termsync) --
|
|
684
|
+
|
|
685
|
+
export function keyExchange(opts?: {
|
|
686
|
+
algorithm?: string;
|
|
687
|
+
publicKey?: Uint8Array;
|
|
688
|
+
sessionId?: string;
|
|
689
|
+
kemPublicKey?: Uint8Array;
|
|
690
|
+
kemCiphertext?: Uint8Array;
|
|
691
|
+
}): WshMessage;
|
|
692
|
+
|
|
693
|
+
export function encryptedFrame(opts?: {
|
|
694
|
+
nonce?: Uint8Array;
|
|
695
|
+
ciphertext?: Uint8Array;
|
|
696
|
+
sessionId?: string;
|
|
697
|
+
}): WshMessage;
|
|
698
|
+
|
|
699
|
+
export function echoAck(opts?: {
|
|
700
|
+
channelId?: number;
|
|
701
|
+
echoSeq?: number;
|
|
702
|
+
}): WshMessage;
|
|
703
|
+
|
|
704
|
+
export function echoState(opts?: {
|
|
705
|
+
channelId?: number;
|
|
706
|
+
echoSeq?: number;
|
|
707
|
+
cursorX?: number;
|
|
708
|
+
cursorY?: number;
|
|
709
|
+
pending?: number;
|
|
710
|
+
}): WshMessage;
|
|
711
|
+
|
|
712
|
+
export function termSync(opts?: {
|
|
713
|
+
channelId?: number;
|
|
714
|
+
frameSeq?: number;
|
|
715
|
+
stateHash?: Uint8Array;
|
|
716
|
+
}): WshMessage;
|
|
717
|
+
|
|
718
|
+
export function termDiff(opts?: {
|
|
719
|
+
channelId?: number;
|
|
720
|
+
frameSeq?: number;
|
|
721
|
+
baseSeq?: number;
|
|
722
|
+
patch?: Uint8Array;
|
|
723
|
+
}): WshMessage;
|
|
724
|
+
|
|
725
|
+
// -- Message constructors (scaling / principals) --
|
|
726
|
+
|
|
727
|
+
export function nodeAnnounce(opts?: {
|
|
728
|
+
nodeId?: string;
|
|
729
|
+
endpoint?: string;
|
|
730
|
+
load?: number;
|
|
731
|
+
capacity?: number;
|
|
732
|
+
}): WshMessage;
|
|
733
|
+
|
|
734
|
+
export function nodeRedirect(opts?: {
|
|
735
|
+
targetNode?: string;
|
|
736
|
+
targetEndpoint?: string;
|
|
737
|
+
sessionId?: string;
|
|
738
|
+
reason?: string;
|
|
739
|
+
}): WshMessage;
|
|
740
|
+
|
|
741
|
+
export function sessionGrant(opts?: {
|
|
742
|
+
sessionId?: string;
|
|
743
|
+
principal?: string;
|
|
744
|
+
permissions?: string[];
|
|
745
|
+
}): WshMessage;
|
|
746
|
+
|
|
747
|
+
export function sessionRevoke(opts?: {
|
|
748
|
+
sessionId?: string;
|
|
749
|
+
principal?: string;
|
|
750
|
+
reason?: string;
|
|
751
|
+
}): WshMessage;
|
|
752
|
+
|
|
753
|
+
// -- Message constructors (file channel / policy / terminal) --
|
|
754
|
+
|
|
755
|
+
export function fileOp(opts?: {
|
|
756
|
+
channelId?: number;
|
|
757
|
+
op?: string;
|
|
758
|
+
path?: string;
|
|
759
|
+
offset?: number;
|
|
760
|
+
length?: number;
|
|
761
|
+
}): WshMessage;
|
|
762
|
+
|
|
763
|
+
export function fileResult(opts?: {
|
|
764
|
+
channelId?: number;
|
|
765
|
+
success?: boolean;
|
|
766
|
+
metadata?: Record<string, unknown>;
|
|
767
|
+
errorMessage?: string;
|
|
768
|
+
}): WshMessage;
|
|
769
|
+
|
|
770
|
+
export function fileChunk(opts?: {
|
|
771
|
+
channelId?: number;
|
|
772
|
+
offset?: number;
|
|
773
|
+
data?: Uint8Array;
|
|
774
|
+
isFinal?: boolean;
|
|
775
|
+
totalSize?: number;
|
|
776
|
+
}): WshMessage;
|
|
777
|
+
|
|
778
|
+
export function policyEval(opts?: {
|
|
779
|
+
requestId?: string;
|
|
780
|
+
action?: string;
|
|
781
|
+
principal?: string;
|
|
782
|
+
context?: Record<string, unknown>;
|
|
783
|
+
}): WshMessage;
|
|
784
|
+
|
|
785
|
+
export function policyResult(opts?: {
|
|
786
|
+
requestId?: string;
|
|
787
|
+
allowed?: boolean;
|
|
788
|
+
reason?: string;
|
|
789
|
+
}): WshMessage;
|
|
790
|
+
|
|
791
|
+
export function policyUpdate(opts?: {
|
|
792
|
+
policyId?: string;
|
|
793
|
+
rules?: Record<string, unknown>;
|
|
794
|
+
version?: number;
|
|
795
|
+
}): WshMessage;
|
|
796
|
+
|
|
797
|
+
export function terminalConfig(opts?: {
|
|
798
|
+
channelId?: number;
|
|
799
|
+
frontend?: string;
|
|
800
|
+
options?: Record<string, unknown>;
|
|
801
|
+
}): WshMessage;
|
|
802
|
+
|
|
539
803
|
// -- Utility --
|
|
540
804
|
|
|
541
805
|
/**
|
|
@@ -595,17 +859,30 @@ export function sign(privateKey: CryptoKey, data: Uint8Array): Promise<Uint8Arra
|
|
|
595
859
|
*/
|
|
596
860
|
export function verify(publicKey: CryptoKey, signature: Uint8Array, data: Uint8Array): Promise<boolean>;
|
|
597
861
|
|
|
862
|
+
/** Options binding extra context into the auth transcript. */
|
|
863
|
+
export interface WshTranscriptOptions {
|
|
864
|
+
/** Username this transcript is bound to (default ''). */
|
|
865
|
+
username?: string;
|
|
866
|
+
/** Reserved for a transport-layer identity binding; no caller populates it yet. */
|
|
867
|
+
channelBinding?: Uint8Array;
|
|
868
|
+
}
|
|
869
|
+
|
|
598
870
|
/**
|
|
599
871
|
* Build the authentication transcript hash for challenge-response signing.
|
|
600
872
|
*
|
|
601
|
-
* transcript = SHA-256(
|
|
873
|
+
* transcript = SHA-256(
|
|
874
|
+
* "wsh-v1\0" || lp(username) || lp(session_id) || nonce || channel_binding
|
|
875
|
+
* )
|
|
876
|
+
*
|
|
877
|
+
* where lp() is a 4-byte big-endian length prefix on the variable-length
|
|
878
|
+
* string fields.
|
|
602
879
|
*
|
|
603
880
|
* @returns 32-byte SHA-256 hash
|
|
604
881
|
*/
|
|
605
882
|
export function buildTranscript(
|
|
606
883
|
sessionId: string,
|
|
607
884
|
nonce: Uint8Array,
|
|
608
|
-
|
|
885
|
+
opts?: WshTranscriptOptions,
|
|
609
886
|
): Promise<Uint8Array>;
|
|
610
887
|
|
|
611
888
|
/**
|
|
@@ -619,7 +896,7 @@ export function signChallenge(
|
|
|
619
896
|
publicKey: CryptoKey,
|
|
620
897
|
sessionId: string,
|
|
621
898
|
nonce: Uint8Array,
|
|
622
|
-
|
|
899
|
+
opts?: WshTranscriptOptions,
|
|
623
900
|
): Promise<{ signature: Uint8Array; publicKeyRaw: Uint8Array }>;
|
|
624
901
|
|
|
625
902
|
/**
|
|
@@ -630,7 +907,7 @@ export function verifyChallenge(
|
|
|
630
907
|
signature: Uint8Array,
|
|
631
908
|
sessionId: string,
|
|
632
909
|
nonce: Uint8Array,
|
|
633
|
-
|
|
910
|
+
opts?: WshTranscriptOptions,
|
|
634
911
|
): Promise<boolean>;
|
|
635
912
|
|
|
636
913
|
/** Fields of a signed peer record (reverse-mode registration). See `signPeerRecord`. */
|
|
@@ -789,18 +1066,194 @@ export class WshTransport {
|
|
|
789
1066
|
*/
|
|
790
1067
|
export class WebTransportTransport extends WshTransport {}
|
|
791
1068
|
|
|
1069
|
+
/**
|
|
1070
|
+
* Dispatch a fixed, already-available batch of items one at a time,
|
|
1071
|
+
* fully awaiting each handler call before dispatching the next. Use
|
|
1072
|
+
* SerialQueue instead for items arriving incrementally via a push
|
|
1073
|
+
* callback.
|
|
1074
|
+
*/
|
|
1075
|
+
export function dispatchSerially<T>(
|
|
1076
|
+
items: Iterable<T>,
|
|
1077
|
+
handler: (item: T) => void | Promise<void>,
|
|
1078
|
+
): Promise<void>;
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* A queue drained one item at a time, awaiting each dispatch before
|
|
1082
|
+
* starting the next. Use where items arrive incrementally over time via
|
|
1083
|
+
* a push callback (e.g. a transport's raw `message` event).
|
|
1084
|
+
*/
|
|
1085
|
+
export class SerialQueue<T = unknown> {
|
|
1086
|
+
constructor(handler: (item: T) => void | Promise<void>);
|
|
1087
|
+
push(item: T): void;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
792
1090
|
// ============================================================================
|
|
793
1091
|
// transport-ws.mjs -- WebSocket transport
|
|
794
1092
|
// ============================================================================
|
|
795
1093
|
|
|
796
1094
|
/**
|
|
797
|
-
* wsh transport over a single WebSocket
|
|
1095
|
+
* wsh transport over a single WebSocket, multiplexed with QMux
|
|
1096
|
+
* (draft-ietf-quic-qmux-02). The control channel is QMux stream 0.
|
|
798
1097
|
*
|
|
799
1098
|
* Provides the same interface as WebTransportTransport so that upper
|
|
800
1099
|
* layers (session, client) work identically over either transport.
|
|
801
1100
|
*/
|
|
802
1101
|
export class WebSocketTransport extends WshTransport {}
|
|
803
1102
|
|
|
1103
|
+
/**
|
|
1104
|
+
* @deprecated The old hand-rolled 5-byte mux's frame-type byte values.
|
|
1105
|
+
* wsh now speaks QMux instead; this export is kept only for source
|
|
1106
|
+
* compatibility and is no longer produced or consumed anywhere.
|
|
1107
|
+
*/
|
|
1108
|
+
export const WS_FRAME_TYPE: {
|
|
1109
|
+
readonly CONTROL: 0x01;
|
|
1110
|
+
readonly DATA: 0x02;
|
|
1111
|
+
readonly OPEN_STREAM: 0x03;
|
|
1112
|
+
readonly CLOSE_STREAM: 0x04;
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
// ============================================================================
|
|
1116
|
+
// qmux.mjs / qmux-connection.mjs -- QMux multiplexing layer
|
|
1117
|
+
// ============================================================================
|
|
1118
|
+
|
|
1119
|
+
/** Default QMux flow-control parameters. */
|
|
1120
|
+
export const QMUX_DEFAULTS: {
|
|
1121
|
+
/** Connection-wide receive window in bytes (8 MiB). */
|
|
1122
|
+
readonly initialMaxData: number;
|
|
1123
|
+
/** Per-stream receive window in bytes (1 MiB). */
|
|
1124
|
+
readonly initialMaxStreamData: number;
|
|
1125
|
+
/** Initial bidirectional stream credit. */
|
|
1126
|
+
readonly initialMaxStreamsBidi: number;
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
/** QUIC transport error codes used by wsh (RFC 9000 Section 20.1 subset). */
|
|
1130
|
+
export const QMUX_ERROR_CODE: {
|
|
1131
|
+
readonly NO_ERROR: 0x00;
|
|
1132
|
+
readonly INTERNAL_ERROR: 0x01;
|
|
1133
|
+
readonly FLOW_CONTROL_ERROR: 0x03;
|
|
1134
|
+
readonly STREAM_LIMIT_ERROR: 0x04;
|
|
1135
|
+
readonly STREAM_STATE_ERROR: 0x05;
|
|
1136
|
+
readonly FINAL_SIZE_ERROR: 0x06;
|
|
1137
|
+
readonly FRAME_ENCODING_ERROR: 0x07;
|
|
1138
|
+
readonly TRANSPORT_PARAMETER_ERROR: 0x08;
|
|
1139
|
+
readonly PROTOCOL_VIOLATION: 0x0a;
|
|
1140
|
+
readonly APPLICATION_ERROR: 0x0c;
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
/** Stream-initiator bit values (QUIC stream-id low bit). */
|
|
1144
|
+
export const QMUX_STREAM_INITIATOR: {
|
|
1145
|
+
readonly CLIENT: 0;
|
|
1146
|
+
readonly SERVER: 1;
|
|
1147
|
+
};
|
|
1148
|
+
|
|
1149
|
+
/** First bidirectional stream ID for the given initiator (0 or 1). */
|
|
1150
|
+
export function firstBidiStreamId(initiator: 0 | 1): number;
|
|
1151
|
+
|
|
1152
|
+
/** Next bidirectional stream ID after `id` for the same initiator (+4). */
|
|
1153
|
+
export function nextBidiStreamId(id: number): number;
|
|
1154
|
+
|
|
1155
|
+
/** Whether a stream ID was opened by the client side. */
|
|
1156
|
+
export function isClientInitiated(streamId: number): boolean;
|
|
1157
|
+
|
|
1158
|
+
/** Whether a stream ID is bidirectional (wsh only uses bidi streams). */
|
|
1159
|
+
export function isBidirectional(streamId: number): boolean;
|
|
1160
|
+
|
|
1161
|
+
/**
|
|
1162
|
+
* One multiplexed QMux stream: a minimal push/pull data API (not DOM
|
|
1163
|
+
* Streams -- WebSocketTransport wraps these in ReadableStream/
|
|
1164
|
+
* WritableStream for the rest of wsh).
|
|
1165
|
+
*/
|
|
1166
|
+
export interface QMuxStream {
|
|
1167
|
+
/** Stream ID. */
|
|
1168
|
+
id: number;
|
|
1169
|
+
|
|
1170
|
+
/** In-order data delivery. */
|
|
1171
|
+
onData: ((data: Uint8Array) => void) | null;
|
|
1172
|
+
|
|
1173
|
+
/** Clean FIN: all data delivered. */
|
|
1174
|
+
onEnd: (() => void) | null;
|
|
1175
|
+
|
|
1176
|
+
/** Peer aborted (after any reliable prefix was delivered). */
|
|
1177
|
+
onReset: ((errorCode: number) => void) | null;
|
|
1178
|
+
|
|
1179
|
+
/** The whole connection died (transport closed/errored). */
|
|
1180
|
+
onDestroy: ((err: Error) => void) | null;
|
|
1181
|
+
|
|
1182
|
+
/** Send state: 'ready' | 'send' | 'reset_sent' | 'data_sent'. */
|
|
1183
|
+
readonly sendState: string;
|
|
1184
|
+
|
|
1185
|
+
/** Receive state: 'recv' | 'size_known' | 'data_recvd' | 'reset_recvd'. */
|
|
1186
|
+
readonly recvState: string;
|
|
1187
|
+
|
|
1188
|
+
/** Write bytes, awaiting flow-control window as needed. */
|
|
1189
|
+
write(bytes: Uint8Array): Promise<void>;
|
|
1190
|
+
|
|
1191
|
+
/** Half-close the send side (send FIN). */
|
|
1192
|
+
close(): Promise<void>;
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Abort the send side. A non-zero `reliableSize` uses RESET_STREAM_AT
|
|
1196
|
+
* (draft-ietf-quic-reliable-stream-reset) to guarantee that prefix is
|
|
1197
|
+
* still delivered.
|
|
1198
|
+
*/
|
|
1199
|
+
reset(errorCode?: number, reliableSize?: number): void;
|
|
1200
|
+
|
|
1201
|
+
/** Tell the peer to stop sending. */
|
|
1202
|
+
stopSending(errorCode?: number): void;
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* QMux connection: stream state machine + windowed flow control over an
|
|
1207
|
+
* ordered, reliable byte stream. Transport-agnostic -- it takes a
|
|
1208
|
+
* `send(bytes)` callback for outbound record bytes and is fed inbound
|
|
1209
|
+
* bytes via `receiveBytes()`. Exported so alternate server
|
|
1210
|
+
* implementations can speak the same framing as WebSocketTransport.
|
|
1211
|
+
*/
|
|
1212
|
+
export class QMuxConnection {
|
|
1213
|
+
constructor(opts: {
|
|
1214
|
+
/** Whether this endpoint is the client (determines stream-id parity). */
|
|
1215
|
+
isClient: boolean;
|
|
1216
|
+
/** Callback receiving encoded QMux record bytes to transmit. */
|
|
1217
|
+
send: (bytes: Uint8Array) => void;
|
|
1218
|
+
initialMaxData?: number;
|
|
1219
|
+
initialMaxStreamData?: number;
|
|
1220
|
+
initialMaxStreamsBidi?: number;
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
/** Called for peer-initiated streams. */
|
|
1224
|
+
onStreamOpen: ((stream: QMuxStream) => void) | null;
|
|
1225
|
+
|
|
1226
|
+
/** Called for DATAGRAM frames. */
|
|
1227
|
+
onDatagram: ((data: Uint8Array) => void) | null;
|
|
1228
|
+
|
|
1229
|
+
/** Called on peer CONNECTION_CLOSE. */
|
|
1230
|
+
onClose: ((errorCode: number, reason: string) => void) | null;
|
|
1231
|
+
|
|
1232
|
+
/** Called on protocol errors. */
|
|
1233
|
+
onError: ((err: Error) => void) | null;
|
|
1234
|
+
|
|
1235
|
+
/** Send the QX_TRANSPORT_PARAMETERS handshake frame -- must be the first frame sent. */
|
|
1236
|
+
sendHandshake(): void;
|
|
1237
|
+
|
|
1238
|
+
/** Open a new locally-initiated bidirectional stream. */
|
|
1239
|
+
openStream(): Promise<QMuxStream>;
|
|
1240
|
+
|
|
1241
|
+
/** Look up an existing stream by ID. */
|
|
1242
|
+
getStream(id: number): QMuxStream | undefined;
|
|
1243
|
+
|
|
1244
|
+
/** Send a DATAGRAM frame. */
|
|
1245
|
+
sendDatagram(bytes: Uint8Array): void;
|
|
1246
|
+
|
|
1247
|
+
/** Feed inbound bytes from the underlying transport. */
|
|
1248
|
+
receiveBytes(chunk: Uint8Array): void;
|
|
1249
|
+
|
|
1250
|
+
/** Send CONNECTION_CLOSE and shut down. */
|
|
1251
|
+
close(errorCode?: number, reason?: string): void;
|
|
1252
|
+
|
|
1253
|
+
/** Forcibly tear down every stream (transport died; nothing is sent). */
|
|
1254
|
+
destroy(err: Error): void;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
804
1257
|
// ============================================================================
|
|
805
1258
|
// session.mjs -- WshSession
|
|
806
1259
|
// ============================================================================
|
|
@@ -881,6 +1334,9 @@ export class WshSession {
|
|
|
881
1334
|
/** Last terminal diff received for the session. */
|
|
882
1335
|
readonly lastTermDiff: Record<string, unknown> | null;
|
|
883
1336
|
|
|
1337
|
+
/** Whether `enableE2E()` has been called and E2E sealing is active for this session. */
|
|
1338
|
+
readonly e2eEnabled: boolean;
|
|
1339
|
+
|
|
884
1340
|
constructor(
|
|
885
1341
|
transport: WshTransport,
|
|
886
1342
|
channelId: number,
|
|
@@ -897,9 +1353,21 @@ export class WshSession {
|
|
|
897
1353
|
/**
|
|
898
1354
|
* Write data to the session's stdin stream.
|
|
899
1355
|
* Accepts a Uint8Array for raw bytes or a string (UTF-8 encoded).
|
|
1356
|
+
* Sealed into an EncryptedFrame instead of plaintext SessionData when
|
|
1357
|
+
* `enableE2E()` has been called (virtual-mode sessions only).
|
|
900
1358
|
*/
|
|
901
1359
|
write(data: Uint8Array | string): Promise<void>;
|
|
902
1360
|
|
|
1361
|
+
/**
|
|
1362
|
+
* Opt in to end-to-end encryption for this session's data plane
|
|
1363
|
+
* (virtual-mode sessions only). `sharedSecret` must come from a fresh
|
|
1364
|
+
* `WshClient.initiateE2E()` call on the *current* connection --
|
|
1365
|
+
* E2E state is connection-scoped, not session-scoped, so a session
|
|
1366
|
+
* Resumed/Attached on a new connection must run `initiateE2E()` and
|
|
1367
|
+
* `enableE2E()` again rather than reusing a prior key.
|
|
1368
|
+
*/
|
|
1369
|
+
enableE2E(sharedSecret: CryptoKey, opts: { role: 'initiator' | 'responder' }): void;
|
|
1370
|
+
|
|
903
1371
|
/**
|
|
904
1372
|
* Request the remote PTY to resize.
|
|
905
1373
|
*/
|
|
@@ -1257,7 +1725,13 @@ export class WshClient {
|
|
|
1257
1725
|
/**
|
|
1258
1726
|
* Download a file from a remote path.
|
|
1259
1727
|
*/
|
|
1260
|
-
download(
|
|
1728
|
+
download(
|
|
1729
|
+
remotePath: string,
|
|
1730
|
+
opts?: {
|
|
1731
|
+
onProgress?: (progress: { received: number; total: number }) => void;
|
|
1732
|
+
timeout?: number;
|
|
1733
|
+
},
|
|
1734
|
+
): Promise<Uint8Array>;
|
|
1261
1735
|
|
|
1262
1736
|
/**
|
|
1263
1737
|
* Discover MCP tools available on the remote server.
|
package/src/messages.gen.mjs
CHANGED
|
@@ -777,9 +777,10 @@ export function keyExchange({ algorithm, publicKey, sessionId, kemPublicKey, kem
|
|
|
777
777
|
return msg;
|
|
778
778
|
}
|
|
779
779
|
|
|
780
|
-
export function encryptedFrame({ nonce, ciphertext, sessionId } = {}) {
|
|
780
|
+
export function encryptedFrame({ channelId, nonce, ciphertext, sessionId } = {}) {
|
|
781
781
|
return {
|
|
782
782
|
type: MSG.ENCRYPTED_FRAME,
|
|
783
|
+
channel_id: channelId,
|
|
783
784
|
nonce,
|
|
784
785
|
ciphertext,
|
|
785
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
|
|
|
@@ -77,6 +78,27 @@ export class WshSession {
|
|
|
77
78
|
/** @type {WshVirtualSessionBackend|null} */
|
|
78
79
|
#virtualBackend = null;
|
|
79
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
|
+
|
|
80
102
|
/**
|
|
81
103
|
* The readable side of the stdout data stream.
|
|
82
104
|
* @type {ReadableStream<Uint8Array>|null}
|
|
@@ -292,6 +314,60 @@ export class WshSession {
|
|
|
292
314
|
this.#state = STATE_ACTIVE;
|
|
293
315
|
}
|
|
294
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
|
+
|
|
295
371
|
// ── Public API ──────────────────────────────────────────────────────
|
|
296
372
|
|
|
297
373
|
/**
|
|
@@ -308,6 +384,15 @@ export class WshSession {
|
|
|
308
384
|
if (this.#virtualBackend === null) {
|
|
309
385
|
throw new Error('Session not yet activated — virtual backend unavailable');
|
|
310
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
|
+
}
|
|
311
396
|
await this.#virtualBackend.write(data);
|
|
312
397
|
return;
|
|
313
398
|
}
|
|
@@ -437,6 +522,40 @@ export class WshSession {
|
|
|
437
522
|
break;
|
|
438
523
|
}
|
|
439
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
|
+
|
|
440
559
|
case MSG.FILE_CHUNK: {
|
|
441
560
|
if (this.#fileChunkWaiter) {
|
|
442
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() };
|