@johnhenry/wsh 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -9
- package/package.json +2 -2
- package/src/e2e-frame.mjs +181 -0
- package/src/index.d.ts +483 -9
- package/src/messages.gen.mjs +2 -1
- package/src/session.mjs +274 -5
- package/src/stream-frame.mjs +372 -0
- package/src/virtual-session.mjs +20 -1
package/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.16.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,181 @@
|
|
|
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 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.
|
|
16
|
+
*
|
|
17
|
+
* ── Nonce construction ─────────────────────────────────────────────
|
|
18
|
+
* Nonces are the AES-GCM-mandated 96 bits (12 bytes), built as:
|
|
19
|
+
*
|
|
20
|
+
* [ 8-byte big-endian monotonic counter ][ 4-byte sender-role tag ]
|
|
21
|
+
*
|
|
22
|
+
* The counter starts at 0 and increments by one for every frame a given
|
|
23
|
+
* sender seals (never reused, per `openFrame`'s strict expected-counter
|
|
24
|
+
* check below). The 4-byte role tag is fixed per sender for the
|
|
25
|
+
* lifetime of one E2E-enabled connection (see `session.mjs`'s
|
|
26
|
+
* `enableE2E({ role })`) and differs between the two peers of a
|
|
27
|
+
* session, so the two directions of one session structurally cannot
|
|
28
|
+
* produce a colliding nonce even under fully concurrent bidirectional
|
|
29
|
+
* traffic -- the 8-byte counter alone would collide if both sides ever
|
|
30
|
+
* reached the same count, but the two peers' tags never match, so the
|
|
31
|
+
* full 12-byte nonces never match either.
|
|
32
|
+
*
|
|
33
|
+
* ── AAD ────────────────────────────────────────────────────────────
|
|
34
|
+
* The frame's `session_id` (UTF-8 bytes) is bound as AES-GCM
|
|
35
|
+
* "additional authenticated data": it's authenticated but never
|
|
36
|
+
* encrypted, so ciphertext can't be spliced from one session onto
|
|
37
|
+
* another by a relay that only ever sees ciphertext (the server, for a
|
|
38
|
+
* genuinely E2E session) -- decryption fails if the session_id supplied
|
|
39
|
+
* to `openFrame` doesn't match the one the ciphertext was sealed under.
|
|
40
|
+
*
|
|
41
|
+
* ── Replay/reorder protection ─────────────────────────────────────
|
|
42
|
+
* `openFrame` requires the frame's nonce counter to be *exactly* the
|
|
43
|
+
* caller-supplied `expectedCounter` -- not merely "not yet seen" or
|
|
44
|
+
* "not too old". This is intentionally strict for v1: any replayed,
|
|
45
|
+
* duplicated, dropped, or reordered frame is rejected outright rather
|
|
46
|
+
* than tolerated or reordered-and-delivered. A later PR can relax this
|
|
47
|
+
* to a sliding window if wsh ever needs to tolerate out-of-order
|
|
48
|
+
* delivery for E2E frames; today's virtual-mode transport already
|
|
49
|
+
* delivers control messages in order, so strict-next-counter is not a
|
|
50
|
+
* practical limitation.
|
|
51
|
+
*
|
|
52
|
+
* ── Key lifetime ───────────────────────────────────────────────────
|
|
53
|
+
* The `key` passed to `sealFrame`/`openFrame` is connection-scoped, not
|
|
54
|
+
* session-scoped: a session that is detached and later Resumed/Attached
|
|
55
|
+
* on a new connection MUST run a fresh `initiateE2E()` and call
|
|
56
|
+
* `enableE2E()` again with the new key. Never persist or reuse a
|
|
57
|
+
* `sharedSecret` (or its counters) across a resume -- see
|
|
58
|
+
* `session.mjs`'s `enableE2E` doc comment.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
const NONCE_LENGTH = 12;
|
|
62
|
+
const COUNTER_LENGTH = 8;
|
|
63
|
+
const ROLE_TAG_LENGTH = 4;
|
|
64
|
+
|
|
65
|
+
const textEncoder = new TextEncoder();
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Encode a non-negative integer counter as an 8-byte big-endian value.
|
|
69
|
+
* @param {number} counter
|
|
70
|
+
* @returns {Uint8Array}
|
|
71
|
+
*/
|
|
72
|
+
function encodeCounter(counter) {
|
|
73
|
+
if (!Number.isInteger(counter) || counter < 0) {
|
|
74
|
+
throw new Error(`e2e-frame: counter must be a non-negative integer, got ${counter}`);
|
|
75
|
+
}
|
|
76
|
+
const bytes = new Uint8Array(COUNTER_LENGTH);
|
|
77
|
+
const view = new DataView(bytes.buffer);
|
|
78
|
+
// DataView.setBigUint64 covers the full 64-bit range; counters here
|
|
79
|
+
// never realistically approach it, but there's no reason to cap early.
|
|
80
|
+
view.setBigUint64(0, BigInt(counter), false);
|
|
81
|
+
return bytes;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the 12-byte nonce for a given (counter, role tag) pair. Exported
|
|
86
|
+
* mainly for tests -- callers should go through `sealFrame`/`openFrame`.
|
|
87
|
+
*
|
|
88
|
+
* @param {number} counter
|
|
89
|
+
* @param {Uint8Array} roleTag - exactly 4 bytes
|
|
90
|
+
* @returns {Uint8Array}
|
|
91
|
+
*/
|
|
92
|
+
export function buildNonce(counter, roleTag) {
|
|
93
|
+
if (!(roleTag instanceof Uint8Array) || roleTag.length !== ROLE_TAG_LENGTH) {
|
|
94
|
+
throw new Error(`e2e-frame: roleTag must be a ${ROLE_TAG_LENGTH}-byte Uint8Array`);
|
|
95
|
+
}
|
|
96
|
+
const nonce = new Uint8Array(NONCE_LENGTH);
|
|
97
|
+
nonce.set(encodeCounter(counter), 0);
|
|
98
|
+
nonce.set(roleTag, COUNTER_LENGTH);
|
|
99
|
+
return nonce;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Seal a plaintext frame under an AES-256-GCM key.
|
|
104
|
+
*
|
|
105
|
+
* @param {CryptoKey} key - non-extractable AES-GCM CryptoKey (e.g. from `client.mjs`'s `initiateE2E`)
|
|
106
|
+
* @param {string} sessionId - bound as AAD
|
|
107
|
+
* @param {Uint8Array} roleTag - 4-byte sender role tag, distinct between the two peers of a session
|
|
108
|
+
* @param {number} counter - this sender's next monotonic send counter (0, 1, 2, ...)
|
|
109
|
+
* @param {Uint8Array} plaintext
|
|
110
|
+
* @returns {Promise<{nonce: Uint8Array, ciphertext: Uint8Array}>}
|
|
111
|
+
*/
|
|
112
|
+
export async function sealFrame(key, sessionId, roleTag, counter, plaintext) {
|
|
113
|
+
if (!key || typeof key !== 'object') {
|
|
114
|
+
throw new Error('e2e-frame: sealFrame requires a CryptoKey');
|
|
115
|
+
}
|
|
116
|
+
const nonce = buildNonce(counter, roleTag);
|
|
117
|
+
const aad = textEncoder.encode(sessionId);
|
|
118
|
+
const ciphertext = new Uint8Array(await crypto.subtle.encrypt(
|
|
119
|
+
{ name: 'AES-GCM', iv: nonce, additionalData: aad },
|
|
120
|
+
key,
|
|
121
|
+
plaintext
|
|
122
|
+
));
|
|
123
|
+
return { nonce, ciphertext };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Open (decrypt + authenticate) a sealed frame.
|
|
128
|
+
*
|
|
129
|
+
* Throws if:
|
|
130
|
+
* - the AEAD authentication tag doesn't verify (tampered ciphertext,
|
|
131
|
+
* wrong key, or `sessionId` mismatch since it's bound as AAD), or
|
|
132
|
+
* - the frame's nonce counter isn't exactly `expectedCounter` (replay,
|
|
133
|
+
* reorder, or drop -- see this module's doc comment).
|
|
134
|
+
*
|
|
135
|
+
* @param {CryptoKey} key
|
|
136
|
+
* @param {string} sessionId - must match what the frame was sealed with (bound as AAD)
|
|
137
|
+
* @param {number} expectedCounter - the exact next counter this peer expects from the sender
|
|
138
|
+
* @param {{nonce: Uint8Array, ciphertext: Uint8Array}} frame
|
|
139
|
+
* @returns {Promise<Uint8Array>} plaintext
|
|
140
|
+
*/
|
|
141
|
+
export async function openFrame(key, sessionId, expectedCounter, { nonce, ciphertext }) {
|
|
142
|
+
if (!key || typeof key !== 'object') {
|
|
143
|
+
throw new Error('e2e-frame: openFrame requires a CryptoKey');
|
|
144
|
+
}
|
|
145
|
+
if (!(nonce instanceof Uint8Array) || nonce.length !== NONCE_LENGTH) {
|
|
146
|
+
throw new Error(`e2e-frame: nonce must be a ${NONCE_LENGTH}-byte Uint8Array`);
|
|
147
|
+
}
|
|
148
|
+
const actualCounter = Number(new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength).getBigUint64(0, false));
|
|
149
|
+
if (actualCounter !== expectedCounter) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`e2e-frame: replay/reorder detected -- expected counter ${expectedCounter}, got ${actualCounter}`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
const aad = textEncoder.encode(sessionId);
|
|
155
|
+
try {
|
|
156
|
+
return new Uint8Array(await crypto.subtle.decrypt(
|
|
157
|
+
{ name: 'AES-GCM', iv: nonce, additionalData: aad },
|
|
158
|
+
key,
|
|
159
|
+
ciphertext
|
|
160
|
+
));
|
|
161
|
+
} catch (err) {
|
|
162
|
+
throw new Error(`e2e-frame: authentication failed while opening frame (${err.message})`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const E2E_NONCE_LENGTH = NONCE_LENGTH;
|
|
167
|
+
export const E2E_ROLE_TAG_LENGTH = ROLE_TAG_LENGTH;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Deterministic 4-byte role tags for the two sides of one E2E-enabled
|
|
171
|
+
* connection. Callers pick a role at `enableE2E()` time -- 'initiator'
|
|
172
|
+
* is naturally the side that called `initiateE2E()` first / sent round-1
|
|
173
|
+
* KeyExchange, 'responder' the other side; any consistent, non-
|
|
174
|
+
* overlapping assignment works since these are just fixed structural
|
|
175
|
+
* tags, not secret values.
|
|
176
|
+
* @type {Record<'initiator'|'responder', Uint8Array>}
|
|
177
|
+
*/
|
|
178
|
+
export const ROLE_TAGS = Object.freeze({
|
|
179
|
+
initiator: Uint8Array.from([0x69, 0x6e, 0x69, 0x74]), // "init"
|
|
180
|
+
responder: Uint8Array.from([0x72, 0x65, 0x73, 0x70]), // "resp"
|
|
181
|
+
});
|