@johnhenry/wsh 0.16.1 → 0.17.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 +208 -10
- package/package.json +9 -1
- package/src/auth.mjs +63 -1
- package/src/client.mjs +339 -35
- package/src/control-listener.mjs +130 -0
- package/src/e2e-frame.mjs +32 -1
- package/src/file-transfer.mjs +11 -45
- package/src/index.d.ts +136 -6
- package/src/index.mjs +6 -2
- package/src/mcp-bridge.mjs +70 -51
- package/src/messages.gen.mjs +8 -7
- package/src/mlkem.mjs +27 -0
- package/src/session.mjs +73 -21
- package/src/transport-ws.mjs +16 -2
- package/src/transport.mjs +201 -7
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ Or via CDN:
|
|
|
28
28
|
|
|
29
29
|
- **Ed25519 authentication** -- challenge-response via Web Crypto API with a transcript binding username and session id, SSH key format support
|
|
30
30
|
- **Dual transport** -- WebTransport (native streams) and WebSocket (QMux-multiplexed streams) with identical API
|
|
31
|
+
- **Self-signed certificate pinning** -- `serverCertificateHashes` on the WebTransport path, so page JavaScript can reach a server whose certificate no certificate authority signed
|
|
31
32
|
- **CBOR encoding** -- compact binary wire format with length-prefixed framing
|
|
32
33
|
- **Session management** -- open, attach, resume, detach, rename PTY/exec sessions, with session-scoped resume tokens and per-principal access grants
|
|
33
34
|
- **Reverse mode** -- register as a peer (via a signed peer record) and accept incoming connections through a relay
|
|
@@ -127,6 +128,67 @@ await client.detach(session.sessionId); // leave it running server-side
|
|
|
127
128
|
await client.listRemoteSessions(); // sessions this key can see
|
|
128
129
|
```
|
|
129
130
|
|
|
131
|
+
## Pinning a Self-Signed Certificate
|
|
132
|
+
|
|
133
|
+
Both transports normally need a certificate a public certificate authority
|
|
134
|
+
signed. On a LAN -- a phone talking to a desktop on `192.168.x.x`, a
|
|
135
|
+
browser talking to a device on the same Wi-Fi -- there is no such
|
|
136
|
+
certificate to be had, and a plaintext `ws://` from an `https://` page is
|
|
137
|
+
blocked as mixed content.
|
|
138
|
+
|
|
139
|
+
WebTransport is the one place in the web platform with an answer:
|
|
140
|
+
`serverCertificateHashes` lets the page pin a specific certificate by
|
|
141
|
+
SHA-256 digest, no certificate authority involved.
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
// The digest can be raw bytes, base64, plain hex, or the colon-separated
|
|
145
|
+
// hex `openssl x509 -fingerprint -sha256 -noout -in cert.pem` prints --
|
|
146
|
+
// the whole `SHA256 Fingerprint=AB:CD:...` line is accepted as-is.
|
|
147
|
+
await client.connect('https://192.168.1.20:4433/wsh', {
|
|
148
|
+
username: 'alice',
|
|
149
|
+
keyPair,
|
|
150
|
+
transport: 'wt',
|
|
151
|
+
webTransport: {
|
|
152
|
+
serverCertificateHashes: [
|
|
153
|
+
'SHA256 Fingerprint=A1:B2:C3:...',
|
|
154
|
+
],
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
`WebTransportTransport` also takes the same options directly, for use with
|
|
160
|
+
`connectWithTransport()`:
|
|
161
|
+
|
|
162
|
+
```js
|
|
163
|
+
const transport = new WebTransportTransport({
|
|
164
|
+
serverCertificateHashes: [certDigestBytes],
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
A malformed digest throws locally (`RangeError` for the wrong length,
|
|
169
|
+
`TypeError` for an unrecognised shape) before any connection is
|
|
170
|
+
attempted -- a wrong hash otherwise surfaces only as an opaque
|
|
171
|
+
`WebTransportError` from `wt.ready`.
|
|
172
|
+
|
|
173
|
+
The constraints are the platform's, not wsh's:
|
|
174
|
+
|
|
175
|
+
- The URL must be `https:`; pinning is HTTP/3 only, with no HTTP/2 fallback.
|
|
176
|
+
- The certificate must use an **ECDSA P-256** key and be valid for **at most
|
|
177
|
+
14 days**, so it has to be reissued on a schedule.
|
|
178
|
+
- Connection pooling is disabled for a pinned connection.
|
|
179
|
+
- Only `sha-256` is accepted as the algorithm.
|
|
180
|
+
|
|
181
|
+
The option applies to the WebTransport rung of the transport ladder only.
|
|
182
|
+
If the WebTransport attempt fails and the client falls back to WebSocket,
|
|
183
|
+
that `wss:` connection is subject to the ordinary certificate-authority
|
|
184
|
+
check again -- there is no WebSocket equivalent of certificate pinning.
|
|
185
|
+
Pass `transport: 'wt'` if you would rather fail than fall back.
|
|
186
|
+
|
|
187
|
+
Any other key in `webTransport` is forwarded to the `WebTransport`
|
|
188
|
+
constructor verbatim (`congestionControl`, `allowPooling`,
|
|
189
|
+
`requireUnreliable`, the `anticipatedConcurrentIncoming*Streams` hints),
|
|
190
|
+
so options the platform gains later need no change here.
|
|
191
|
+
|
|
130
192
|
## API Overview
|
|
131
193
|
|
|
132
194
|
### Core Classes
|
|
@@ -136,7 +198,7 @@ await client.listRemoteSessions(); // sessions this key can see
|
|
|
136
198
|
| `WshClient` | Full lifecycle client: connect, auth, sessions, reverse mode, MCP |
|
|
137
199
|
| `WshSession` | Single PTY or exec channel with read/write/resize/signal |
|
|
138
200
|
| `WshTransport` | Abstract transport base class |
|
|
139
|
-
| `WebTransportTransport` | WebTransport implementation (native streams) |
|
|
201
|
+
| `WebTransportTransport` | WebTransport implementation (native streams); takes `serverCertificateHashes` and other `WebTransport` options |
|
|
140
202
|
| `WebSocketTransport` | WebSocket implementation (multiplexed virtual streams) |
|
|
141
203
|
|
|
142
204
|
### Utilities
|
|
@@ -152,6 +214,8 @@ await client.listRemoteSessions(); // sessions this key can see
|
|
|
152
214
|
| `signChallenge()` | Build transcript + sign for auth handshake |
|
|
153
215
|
| `signPeerRecord()` / `verifyPeerRecord()` | Sign / verify reverse-mode peer records |
|
|
154
216
|
| `fingerprint()` | SHA-256 hex fingerprint of a public key |
|
|
217
|
+
| `parseCertificateHash()` | Decode a certificate digest from hex / base64 / bytes |
|
|
218
|
+
| `normalizeWebTransportOptions()` | Build a `WebTransportOptions` dictionary from loose input |
|
|
155
219
|
|
|
156
220
|
### Protocol
|
|
157
221
|
|
|
@@ -172,6 +236,77 @@ The `spec/` directory contains the protocol definition:
|
|
|
172
236
|
- `wsh-v1.md` -- human-readable protocol specification
|
|
173
237
|
- `codegen.mjs` -- generates `messages.gen.mjs` from the YAML spec
|
|
174
238
|
|
|
239
|
+
## Rust implementation
|
|
240
|
+
|
|
241
|
+
`crates/` is a native Rust implementation of the wsh protocol, moved into
|
|
242
|
+
this repo from erisera-code/clawser on 2026-09-06 (with history, via `git
|
|
243
|
+
subtree`) so it lives next to the JS client whose wire spec it implements
|
|
244
|
+
instead of dragging its own toolchain and CI job into an unrelated
|
|
245
|
+
browser-app repo. It was previously deleted from clawser by accident
|
|
246
|
+
(2026-03-14) and restored (2026-07-05) before this move.
|
|
247
|
+
|
|
248
|
+
Four crates, workspace-versioned at `0.1.0`:
|
|
249
|
+
|
|
250
|
+
| Crate | Kind | Description |
|
|
251
|
+
|-------|------|--------------|
|
|
252
|
+
| `wsh-core` | library | Shared protocol types -- CBOR messages (generated, see below), codec, identity, auth, QMux |
|
|
253
|
+
| `wsh-client` | library | Native Rust client -- WebTransport/WebSocket transports, sessions, file transfer, E2E |
|
|
254
|
+
| `wsh-cli` | binary (`wsh`) | SSH-like CLI: connect, exec, scp-style copy, reverse tunnels, key management |
|
|
255
|
+
| `wsh-server` | binary (`wsh-server`) | Server: WebTransport/WebSocket listener, real PTY sessions (`portable-pty`), relay/reverse-connect, WISP bridging |
|
|
256
|
+
|
|
257
|
+
### Build and test
|
|
258
|
+
|
|
259
|
+
```sh
|
|
260
|
+
cargo build --workspace
|
|
261
|
+
cargo test --workspace
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
The Node-to-Rust cross-implementation tests (`test/rust/*.test.mjs`) spawn
|
|
265
|
+
the real `wsh-server` binary and drive it with this repo's own JS client --
|
|
266
|
+
they're kept out of the default `npm test` glob so JS-only contributors
|
|
267
|
+
never need a Rust toolchain. Build the release binary first (or set
|
|
268
|
+
`WSH_SERVER_BIN` to point at one), then:
|
|
269
|
+
|
|
270
|
+
```sh
|
|
271
|
+
cargo build --release -p wsh-server
|
|
272
|
+
npm run test:rust
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Codegen: regenerate, don't hand-edit
|
|
276
|
+
|
|
277
|
+
`crates/wsh-core/src/messages.gen.rs` is generated from `spec/wsh-v1.yaml`
|
|
278
|
+
by `spec/codegen.mjs`, alongside the JS (`src/messages.gen.mjs`) and
|
|
279
|
+
Markdown (`spec/wsh-v1.md`) outputs -- all three come from the same schema
|
|
280
|
+
and must never be hand-edited. After changing `wsh-v1.yaml`:
|
|
281
|
+
|
|
282
|
+
```sh
|
|
283
|
+
npm run codegen # regenerate all three outputs
|
|
284
|
+
npm run codegen:check # CI check: fails if any output has drifted from the schema
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
### Release binaries
|
|
288
|
+
|
|
289
|
+
Tagging `rust-vX.Y.Z` (or running the `Release Rust binaries` workflow
|
|
290
|
+
manually with a tag input) builds and publishes prebuilt `wsh-server` and
|
|
291
|
+
`wsh-cli` binaries for four targets to a GitHub Release:
|
|
292
|
+
|
|
293
|
+
- `x86_64-unknown-linux-gnu`
|
|
294
|
+
- `aarch64-apple-darwin`
|
|
295
|
+
- `x86_64-apple-darwin`
|
|
296
|
+
- `i686-unknown-linux-musl` (the target clawser's demo-linux guest image needs)
|
|
297
|
+
|
|
298
|
+
Each target produces two archives, each containing a single binary at the
|
|
299
|
+
archive root:
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
wsh-server-<target>.tar.gz # binary: wsh-server
|
|
303
|
+
wsh-cli-<target>.tar.gz # binary: wsh
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
plus one `SHA256SUMS` file covering every archive in the release. These
|
|
307
|
+
names are a stable contract other repos (clawser) pin and download by --
|
|
308
|
+
do not change them without coordinating downstream.
|
|
309
|
+
|
|
175
310
|
## Security
|
|
176
311
|
|
|
177
312
|
- **Auth transcript binding** -- challenge signatures cover
|
|
@@ -193,17 +328,80 @@ The `spec/` directory contains the protocol definition:
|
|
|
193
328
|
|
|
194
329
|
## Browser Compatibility
|
|
195
330
|
|
|
196
|
-
Requires a browser (or Node.js 24+) with
|
|
331
|
+
Requires a browser (or Node.js 24+) with `TextEncoder`/`TextDecoder`,
|
|
332
|
+
`ReadableStream`/`WritableStream`, `WebSocket` (universal), and:
|
|
333
|
+
|
|
334
|
+
### WebCrypto Ed25519 -- the real floor
|
|
197
335
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
-
|
|
336
|
+
This is what actually gates the library, because pubkey auth is not
|
|
337
|
+
optional to the protocol. Roughly: **Safari 17+, Chrome/Edge 137+,
|
|
338
|
+
Firefox 130+, Node 20+**.
|
|
339
|
+
|
|
340
|
+
There is deliberately **no pure-JS fallback**. A JS Ed25519 needs the
|
|
341
|
+
private scalar as ordinary bytes, which would give up the property
|
|
342
|
+
`WshKeyStore` is built around -- keys are non-extractable `CryptoKey`
|
|
343
|
+
objects by default, so a compromised page can *use* a key but cannot
|
|
344
|
+
exfiltrate it. Trading that away on exactly the oldest, least-patched
|
|
345
|
+
engines is the wrong direction, and doing it silently would be worse.
|
|
346
|
+
|
|
347
|
+
Ask before you commit to pubkey auth:
|
|
348
|
+
|
|
349
|
+
```js
|
|
350
|
+
import { isEd25519Supported } from '@johnhenry/wsh';
|
|
351
|
+
|
|
352
|
+
if (await isEd25519Supported()) {
|
|
353
|
+
await client.connect(url, { username, keyPair });
|
|
354
|
+
} else {
|
|
355
|
+
await client.connect(url, { username, password });
|
|
356
|
+
}
|
|
357
|
+
```
|
|
203
358
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
359
|
+
`isEd25519Supported()` measures by generating a key rather than sniffing a
|
|
360
|
+
version string, and memoizes. `generateKeyPair()` throws with an
|
|
361
|
+
actionable message where support is missing, rather than passing the
|
|
362
|
+
platform's "Unrecognized name" straight through.
|
|
363
|
+
|
|
364
|
+
### WebTransport
|
|
365
|
+
|
|
366
|
+
WebTransport is optional -- the transport ladder falls back to WebSocket.
|
|
367
|
+
It matters if you want `serverCertificateHashes` (see [Pinning a
|
|
368
|
+
Self-Signed Certificate](#pinning-a-self-signed-certificate)), which has
|
|
369
|
+
no WebSocket equivalent.
|
|
370
|
+
|
|
371
|
+
**Safari supports WebTransport, and more of it than Chromium does.**
|
|
372
|
+
That is the opposite of the usual assumption, and the earlier version of
|
|
373
|
+
this section propagated the assumption by listing only Chrome, Edge and
|
|
374
|
+
Firefox. Measured on 2026-09-04, both engines on a `localhost` secure
|
|
375
|
+
context:
|
|
376
|
+
|
|
377
|
+
| | `WebTransport.prototype` members | Ed25519 | X25519 | ML-KEM-768 |
|
|
378
|
+
|---|---|---|---|---|
|
|
379
|
+
| WebKit -- Safari 26.5, iOS 26.5 simulator | **17** | OK | OK | fails (`TypeError`) |
|
|
380
|
+
| Chromium 148, macOS | 10 | OK | OK | fails (`NotSupportedError`) |
|
|
381
|
+
|
|
382
|
+
WebKit's extra seven are `congestionControl`, `reliability`, `draining`,
|
|
383
|
+
`getStats`, `createSendGroup`, and the two
|
|
384
|
+
`anticipatedConcurrentIncoming*Streams` hints; both engines have
|
|
385
|
+
`datagrams`, both bidirectional and unidirectional stream creation, and
|
|
386
|
+
`ready`/`closed`.
|
|
387
|
+
|
|
388
|
+
Approximate first-support versions, which are *not* measured here: Chrome
|
|
389
|
+
and Edge 97, Firefox 114, Safari 26. Below Safari 26, the ladder falls
|
|
390
|
+
back to WebSocket.
|
|
391
|
+
|
|
392
|
+
Both engines accept a `serverCertificateHashes` entry without a
|
|
393
|
+
synchronous throw, but that is weak evidence on its own -- unknown WebIDL
|
|
394
|
+
dictionary members are silently ignored, so acceptance does not prove the
|
|
395
|
+
option is honoured. Confirming it needs a real HTTP/3 server presenting a
|
|
396
|
+
matching short-lived certificate.
|
|
397
|
+
|
|
398
|
+
### ML-KEM-768
|
|
399
|
+
|
|
400
|
+
The hybrid post-quantum path prefers native WebCrypto ML-KEM (Node 24.7+).
|
|
401
|
+
**No browser tested has it** -- it failed on both engines above -- so in a
|
|
402
|
+
browser the optional `@noble/post-quantum` dependency is what actually
|
|
403
|
+
runs, loaded dynamically by `src/mlkem.mjs`. Treat it as required, not
|
|
404
|
+
optional, if you want the hybrid handshake on the web today.
|
|
207
405
|
|
|
208
406
|
## License
|
|
209
407
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/wsh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
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",
|
|
@@ -16,8 +16,16 @@
|
|
|
16
16
|
"optionalDependencies": {
|
|
17
17
|
"@noble/post-quantum": "^0.7.0"
|
|
18
18
|
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@fails-components/webtransport": "^1.6.4",
|
|
21
|
+
"@fails-components/webtransport-transport-http3-quiche": "^1.6.4",
|
|
22
|
+
"ws": "^8.21.0"
|
|
23
|
+
},
|
|
19
24
|
"scripts": {
|
|
20
25
|
"test": "node --test test/*.test.mjs",
|
|
26
|
+
"test:rust": "node --test test/rust/*.test.mjs",
|
|
27
|
+
"codegen": "node spec/codegen.mjs",
|
|
28
|
+
"codegen:check": "node scripts/codegen-check.mjs",
|
|
21
29
|
"examples": "for f in examples/0*.mjs; do echo \"== $f\" && node \"$f\" || exit 1; done",
|
|
22
30
|
"example:01": "node examples/01-frames-survive-fragmented-transport.mjs",
|
|
23
31
|
"example:02": "node examples/02-tampered-challenge-fails-authentication.mjs",
|
package/src/auth.mjs
CHANGED
|
@@ -5,15 +5,77 @@
|
|
|
5
5
|
|
|
6
6
|
import { PROTOCOL_VERSION } from './messages.mjs';
|
|
7
7
|
|
|
8
|
+
// ── Capability detection ──────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
/** @type {Promise<boolean>|null} Memoized: the answer cannot change at runtime. */
|
|
11
|
+
let ed25519Support = null;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether this runtime's WebCrypto implements Ed25519.
|
|
15
|
+
*
|
|
16
|
+
* There is deliberately no pure-JS fallback behind this. A JS
|
|
17
|
+
* implementation needs the private scalar as ordinary bytes, which would
|
|
18
|
+
* quietly give up the property `WshKeyStore` is built around -- keys are
|
|
19
|
+
* non-extractable `CryptoKey` objects by default, so a compromised page
|
|
20
|
+
* can use a key but cannot exfiltrate it. Trading that away on exactly
|
|
21
|
+
* the oldest, least-patched engines is the wrong direction, and doing it
|
|
22
|
+
* silently is worse. So wsh's floor is WebCrypto Ed25519, and this is how
|
|
23
|
+
* an application asks about it before committing to pubkey auth:
|
|
24
|
+
*
|
|
25
|
+
* ```js
|
|
26
|
+
* if (await isEd25519Supported()) {
|
|
27
|
+
* await client.connect(url, { username, keyPair });
|
|
28
|
+
* } else {
|
|
29
|
+
* await client.connect(url, { username, password }); // AUTH_METHOD.PASSWORD
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* Measured directly rather than sniffed from a version string: `'Ed25519'`
|
|
34
|
+
* is accepted as an algorithm name by some engines that then fail at
|
|
35
|
+
* `generateKey`, so nothing short of generating a key is conclusive.
|
|
36
|
+
*
|
|
37
|
+
* @returns {Promise<boolean>}
|
|
38
|
+
*/
|
|
39
|
+
export async function isEd25519Supported() {
|
|
40
|
+
if (ed25519Support === null) {
|
|
41
|
+
ed25519Support = (async () => {
|
|
42
|
+
try {
|
|
43
|
+
await crypto.subtle.generateKey('Ed25519', false, ['sign', 'verify']);
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
}
|
|
50
|
+
return ed25519Support;
|
|
51
|
+
}
|
|
52
|
+
|
|
8
53
|
// ── Key Generation ────────────────────────────────────────────────────
|
|
9
54
|
|
|
10
55
|
/**
|
|
11
56
|
* Generate a new Ed25519 key pair.
|
|
57
|
+
*
|
|
58
|
+
* Throws where WebCrypto has no Ed25519 -- see `isEd25519Supported()` for
|
|
59
|
+
* why there is no fallback, and for how to check before you get here.
|
|
60
|
+
*
|
|
12
61
|
* @param {boolean} [extractable=false] - Whether private key can be exported
|
|
13
62
|
* @returns {Promise<CryptoKeyPair>} { publicKey, privateKey }
|
|
14
63
|
*/
|
|
15
64
|
export async function generateKeyPair(extractable = false) {
|
|
16
|
-
|
|
65
|
+
try {
|
|
66
|
+
return await crypto.subtle.generateKey('Ed25519', extractable, ['sign', 'verify']);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
// The platform's own message is typically "Unrecognized name" or a
|
|
69
|
+
// bare "Type error", neither of which says which algorithm, which
|
|
70
|
+
// library wanted it, or what a caller might do instead.
|
|
71
|
+
throw new Error(
|
|
72
|
+
'wsh requires WebCrypto Ed25519, which this runtime does not implement ' +
|
|
73
|
+
'(Safari 17+, Chrome/Edge 137+, Firefox 130+, Node 20+). Check ' +
|
|
74
|
+
'isEd25519Supported() first and fall back to password auth, or supply ' +
|
|
75
|
+
`a CryptoKeyPair from elsewhere. (${err?.message || err})`,
|
|
76
|
+
{ cause: err }
|
|
77
|
+
);
|
|
78
|
+
}
|
|
17
79
|
}
|
|
18
80
|
|
|
19
81
|
// ── Export / Import ───────────────────────────────────────────────────
|