@johnhenry/wsh 0.16.1 → 0.17.1
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 +349 -28
- package/package.json +16 -3
- package/src/auth.mjs +63 -1
- package/src/client.mjs +379 -35
- package/src/control-listener.mjs +130 -0
- package/src/e2e-frame.mjs +32 -1
- package/src/file-transfer.mjs +47 -192
- package/src/index.d.ts +261 -8
- package/src/index.mjs +10 -2
- package/src/known-hosts.mjs +139 -0
- package/src/mcp-bridge.mjs +70 -51
- package/src/messages.gen.mjs +30 -16
- 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
|
@@ -1,15 +1,40 @@
|
|
|
1
1
|
# @johnhenry/wsh
|
|
2
2
|
|
|
3
|
-
Full documentation: [opensource.johnhenry.me/wsh](https://opensource.johnhenry.me/wsh/)
|
|
4
|
-
|
|
5
3
|
> Previously published as `wsh-upon-star` (last release: 0.1.1, now deprecated).
|
|
6
|
-
> Renamed to `@johnhenry/wsh`
|
|
7
|
-
>
|
|
4
|
+
> Renamed to `@johnhenry/wsh` on import into the @johnhenry family. Unlike
|
|
5
|
+
> other packages in this family, wsh did **not** restart its version at
|
|
6
|
+
> `0.0.0` on adoption — it was already a mature, actively-depended-upon
|
|
7
|
+
> release, so versioning continued forward and now stands at `0.17.0`. This
|
|
8
|
+
> is a deliberate, permanent exception to the family's usual 0.0.0-restart
|
|
9
|
+
> convention, not an oversight.
|
|
10
|
+
|
|
11
|
+
[](https://www.npmjs.com/package/@johnhenry/wsh)
|
|
12
|
+
[](https://github.com/johnhenry/wsh/actions/workflows/ci.yml)
|
|
13
|
+
[](LICENSE)
|
|
14
|
+
|
|
15
|
+
Full documentation: [opensource.johnhenry.me/wsh](https://opensource.johnhenry.me/wsh/)
|
|
8
16
|
|
|
9
17
|
Browser-native remote command execution over WebTransport/WebSocket with Ed25519 authentication.
|
|
10
18
|
|
|
11
19
|
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
20
|
|
|
21
|
+
## Contents
|
|
22
|
+
|
|
23
|
+
- [Install](#install)
|
|
24
|
+
- [Features](#features)
|
|
25
|
+
- [Wire Protocol: QMux](#wire-protocol-qmux)
|
|
26
|
+
- [Quick Start](#quick-start)
|
|
27
|
+
- [One-Shot Command Execution](#one-shot-command-execution)
|
|
28
|
+
- [Attach and Resume](#attach-and-resume)
|
|
29
|
+
- [Pinning a Self-Signed Certificate](#pinning-a-self-signed-certificate)
|
|
30
|
+
- [API Overview](#api-overview)
|
|
31
|
+
- [Protocol Specification](#protocol-specification)
|
|
32
|
+
- [Rust implementation](#rust-implementation)
|
|
33
|
+
- [Security model](#security-model)
|
|
34
|
+
- [Browser Compatibility](#browser-compatibility)
|
|
35
|
+
- [Family](#family)
|
|
36
|
+
- [License](#license)
|
|
37
|
+
|
|
13
38
|
## Install
|
|
14
39
|
|
|
15
40
|
```bash
|
|
@@ -28,14 +53,28 @@ Or via CDN:
|
|
|
28
53
|
|
|
29
54
|
- **Ed25519 authentication** -- challenge-response via Web Crypto API with a transcript binding username and session id, SSH key format support
|
|
30
55
|
- **Dual transport** -- WebTransport (native streams) and WebSocket (QMux-multiplexed streams) with identical API
|
|
56
|
+
- **Self-signed certificate pinning** -- `serverCertificateHashes` on the WebTransport path, so page JavaScript can reach a server whose certificate no certificate authority signed
|
|
31
57
|
- **CBOR encoding** -- compact binary wire format with length-prefixed framing
|
|
32
58
|
- **Session management** -- open, attach, resume, detach, rename PTY/exec sessions, with session-scoped resume tokens and per-principal access grants
|
|
33
59
|
- **Reverse mode** -- register as a peer (via a signed peer record) and accept incoming connections through a relay
|
|
34
60
|
- **File transfer** -- scp-like upload/download as `FileChunk` control messages in 64KB chunks
|
|
35
61
|
- **MCP bridge** -- discover and invoke remote MCP tools through the control channel
|
|
36
|
-
- **Session recording** -- asciicast v2
|
|
62
|
+
- **Session recording** -- own JSON schema (not asciicast v2 -- see note below) recording and playback with seek/pause/resume
|
|
37
63
|
- **Key management** -- IndexedDB storage with OPFS encrypted backup (PBKDF2 + AES-256-GCM)
|
|
38
|
-
- **
|
|
64
|
+
- **97 message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, authorized-key management, and more
|
|
65
|
+
|
|
66
|
+
> **Session recording is not asciicast v2.** Both `SessionRecorder`
|
|
67
|
+
> implementations capture more than asciicast v2's three event codes
|
|
68
|
+
> (`o`/`i`/`r`) can represent -- session lifecycle events (`open`/`exit` in
|
|
69
|
+
> JS; `Start`/`Exit`/`Snapshot` in Rust) have no asciicast v2 equivalent,
|
|
70
|
+
> and dropping them wasn't worth it just to match the format. The JS and
|
|
71
|
+
> Rust recorders also use two different, mutually incompatible schemas from
|
|
72
|
+
> each other (neither is asciicast v2's newline-delimited-JSON shape).
|
|
73
|
+
> `RecordingExport`'s `format: "asciicast"` option is accepted on the wire
|
|
74
|
+
> but not yet implemented server-side -- requesting it currently returns
|
|
75
|
+
> the same custom JSONL as `format: "jsonl"`. If interop with standard
|
|
76
|
+
> asciicast players (`asciinema play`, etc.) is wanted, that needs a real
|
|
77
|
+
> (lossy -- lifecycle events would be dropped) export path, not a rename.
|
|
39
78
|
|
|
40
79
|
## Wire Protocol: QMux
|
|
41
80
|
|
|
@@ -127,6 +166,67 @@ await client.detach(session.sessionId); // leave it running server-side
|
|
|
127
166
|
await client.listRemoteSessions(); // sessions this key can see
|
|
128
167
|
```
|
|
129
168
|
|
|
169
|
+
## Pinning a Self-Signed Certificate
|
|
170
|
+
|
|
171
|
+
Both transports normally need a certificate a public certificate authority
|
|
172
|
+
signed. On a LAN -- a phone talking to a desktop on `192.168.x.x`, a
|
|
173
|
+
browser talking to a device on the same Wi-Fi -- there is no such
|
|
174
|
+
certificate to be had, and a plaintext `ws://` from an `https://` page is
|
|
175
|
+
blocked as mixed content.
|
|
176
|
+
|
|
177
|
+
WebTransport is the one place in the web platform with an answer:
|
|
178
|
+
`serverCertificateHashes` lets the page pin a specific certificate by
|
|
179
|
+
SHA-256 digest, no certificate authority involved.
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
// The digest can be raw bytes, base64, plain hex, or the colon-separated
|
|
183
|
+
// hex `openssl x509 -fingerprint -sha256 -noout -in cert.pem` prints --
|
|
184
|
+
// the whole `SHA256 Fingerprint=AB:CD:...` line is accepted as-is.
|
|
185
|
+
await client.connect('https://192.168.1.20:4433/wsh', {
|
|
186
|
+
username: 'alice',
|
|
187
|
+
keyPair,
|
|
188
|
+
transport: 'wt',
|
|
189
|
+
webTransport: {
|
|
190
|
+
serverCertificateHashes: [
|
|
191
|
+
'SHA256 Fingerprint=A1:B2:C3:...',
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`WebTransportTransport` also takes the same options directly, for use with
|
|
198
|
+
`connectWithTransport()`:
|
|
199
|
+
|
|
200
|
+
```js
|
|
201
|
+
const transport = new WebTransportTransport({
|
|
202
|
+
serverCertificateHashes: [certDigestBytes],
|
|
203
|
+
});
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
A malformed digest throws locally (`RangeError` for the wrong length,
|
|
207
|
+
`TypeError` for an unrecognised shape) before any connection is
|
|
208
|
+
attempted -- a wrong hash otherwise surfaces only as an opaque
|
|
209
|
+
`WebTransportError` from `wt.ready`.
|
|
210
|
+
|
|
211
|
+
The constraints are the platform's, not wsh's:
|
|
212
|
+
|
|
213
|
+
- The URL must be `https:`; pinning is HTTP/3 only, with no HTTP/2 fallback.
|
|
214
|
+
- The certificate must use an **ECDSA P-256** key and be valid for **at most
|
|
215
|
+
14 days**, so it has to be reissued on a schedule.
|
|
216
|
+
- Connection pooling is disabled for a pinned connection.
|
|
217
|
+
- Only `sha-256` is accepted as the algorithm.
|
|
218
|
+
|
|
219
|
+
The option applies to the WebTransport rung of the transport ladder only.
|
|
220
|
+
If the WebTransport attempt fails and the client falls back to WebSocket,
|
|
221
|
+
that `wss:` connection is subject to the ordinary certificate-authority
|
|
222
|
+
check again -- there is no WebSocket equivalent of certificate pinning.
|
|
223
|
+
Pass `transport: 'wt'` if you would rather fail than fall back.
|
|
224
|
+
|
|
225
|
+
Any other key in `webTransport` is forwarded to the `WebTransport`
|
|
226
|
+
constructor verbatim (`congestionControl`, `allowPooling`,
|
|
227
|
+
`requireUnreliable`, the `anticipatedConcurrentIncoming*Streams` hints),
|
|
228
|
+
so options the platform gains later need no change here.
|
|
229
|
+
|
|
130
230
|
## API Overview
|
|
131
231
|
|
|
132
232
|
### Core Classes
|
|
@@ -136,7 +236,7 @@ await client.listRemoteSessions(); // sessions this key can see
|
|
|
136
236
|
| `WshClient` | Full lifecycle client: connect, auth, sessions, reverse mode, MCP |
|
|
137
237
|
| `WshSession` | Single PTY or exec channel with read/write/resize/signal |
|
|
138
238
|
| `WshTransport` | Abstract transport base class |
|
|
139
|
-
| `WebTransportTransport` | WebTransport implementation (native streams) |
|
|
239
|
+
| `WebTransportTransport` | WebTransport implementation (native streams); takes `serverCertificateHashes` and other `WebTransport` options |
|
|
140
240
|
| `WebSocketTransport` | WebSocket implementation (multiplexed virtual streams) |
|
|
141
241
|
|
|
142
242
|
### Utilities
|
|
@@ -146,18 +246,20 @@ await client.listRemoteSessions(); // sessions this key can see
|
|
|
146
246
|
| `WshKeyStore` | Ed25519 key management via IndexedDB + OPFS encrypted backup |
|
|
147
247
|
| `WshFileTransfer` | File upload/download over dedicated streams |
|
|
148
248
|
| `WshMcpBridge` | Remote MCP tool discovery and invocation |
|
|
149
|
-
| `SessionRecorder` | Record PTY I/O with timestamps (asciicast v2) |
|
|
249
|
+
| `SessionRecorder` | Record PTY I/O with timestamps (own schema, not asciicast v2) |
|
|
150
250
|
| `SessionPlayer` | Replay recordings with original timing |
|
|
151
251
|
| `generateKeyPair()` | Create Ed25519 key pair via Web Crypto |
|
|
152
252
|
| `signChallenge()` | Build transcript + sign for auth handshake |
|
|
153
253
|
| `signPeerRecord()` / `verifyPeerRecord()` | Sign / verify reverse-mode peer records |
|
|
154
254
|
| `fingerprint()` | SHA-256 hex fingerprint of a public key |
|
|
255
|
+
| `parseCertificateHash()` | Decode a certificate digest from hex / base64 / bytes |
|
|
256
|
+
| `normalizeWebTransportOptions()` | Build a `WebTransportOptions` dictionary from loose input |
|
|
155
257
|
|
|
156
258
|
### Protocol
|
|
157
259
|
|
|
158
260
|
| Export | Description |
|
|
159
261
|
|--------|-------------|
|
|
160
|
-
| `MSG` |
|
|
262
|
+
| `MSG` | 97 message type constants (hex opcodes) |
|
|
161
263
|
| `CHANNEL_KIND` | Channel types: `pty`, `exec`, `meta`, `file`, `tcp`, `udp`, `job` |
|
|
162
264
|
| `AUTH_METHOD` | Auth methods: `pubkey`, `password` |
|
|
163
265
|
| `cborEncode` / `cborDecode` | CBOR codec (maps, arrays, strings, ints, bytes, bools, null, floats) |
|
|
@@ -172,38 +274,257 @@ The `spec/` directory contains the protocol definition:
|
|
|
172
274
|
- `wsh-v1.md` -- human-readable protocol specification
|
|
173
275
|
- `codegen.mjs` -- generates `messages.gen.mjs` from the YAML spec
|
|
174
276
|
|
|
175
|
-
##
|
|
277
|
+
## Rust implementation
|
|
278
|
+
|
|
279
|
+
`crates/` is a native Rust implementation of the wsh protocol, moved into
|
|
280
|
+
this repo from erisera-code/clawser on 2026-09-06 (with history, via `git
|
|
281
|
+
subtree`) so it lives next to the JS client whose wire spec it implements
|
|
282
|
+
instead of dragging its own toolchain and CI job into an unrelated
|
|
283
|
+
browser-app repo. It was previously deleted from clawser by accident
|
|
284
|
+
(2026-03-14) and restored (2026-07-05) before this move.
|
|
285
|
+
|
|
286
|
+
Four crates, workspace-versioned at `0.1.0`:
|
|
287
|
+
|
|
288
|
+
| Crate | Kind | Description |
|
|
289
|
+
|-------|------|--------------|
|
|
290
|
+
| `wsh-core` | library | Shared protocol types -- CBOR messages (generated, see below), codec, identity, auth, QMux |
|
|
291
|
+
| `wsh-client` | library | Native Rust client -- WebTransport/WebSocket transports, sessions, file transfer, E2E |
|
|
292
|
+
| `wsh-cli` | binary (`wsh`) | SSH-like CLI: connect, exec, scp-style copy, reverse tunnels, key management |
|
|
293
|
+
| `wsh-server` | binary (`wsh-server`) | Server: WebTransport/WebSocket listener, real PTY sessions (`portable-pty`), relay/reverse-connect, WISP bridging |
|
|
294
|
+
|
|
295
|
+
### Capability matrix: JS SDK vs Rust CLI/client
|
|
296
|
+
|
|
297
|
+
Two implementations of one protocol will always have *some* asymmetry --
|
|
298
|
+
the question wsh #59 asks is whether it is visible and deliberate, or
|
|
299
|
+
discovered by accident. This table is that answer, made explicit rather
|
|
300
|
+
than left to be found.
|
|
301
|
+
|
|
302
|
+
| Capability | JS SDK (browser) | Rust CLI/client | Unified via |
|
|
303
|
+
|---|---|---|---|
|
|
304
|
+
| Directory listing (`list`) | `WshFileTransfer.list()` | `wsh_client::file_transfer::list()`, `wsh ls`, `wsh sftp` | **Wire-unified**: `FileOp`/`FileResult` with the generated `FileEntry` type (wsh #59) -- both languages decode the same shape, including symlink targets |
|
|
305
|
+
| Upload/download | `WshClient.upload()`/`.download()` | `file_transfer::upload()`/`download()`, `wsh scp` | **Wire-unified**: `FileChunk` over a `'file'`-kind channel (wsh #13) |
|
|
306
|
+
| Remove a remote file | `WshClient.fileRemove()` | `file_transfer::remove()`, `wsh sftp`'s `rm` | **Wire-unified**: `FileOp`/`FileResult` (`op: "remove"`) -- refused today by every `wsh-server` release ("not yet implemented"), the same refusal on both sides |
|
|
307
|
+
| Install an authorized key | `WshClient.addAuthorizedKey()` | `wsh_client::WshClient::add_authorized_key()`, `wsh copy-id` | **Wire-unified** (wsh #59): `AuthorizedKeyAdd`/`AuthorizedKeyResult`, replacing a Rust-CLI-only shell command with a message every implementation can send |
|
|
308
|
+
| Host identity / TOFU | `WshKnownHosts` (localStorage-backed) | `KnownHosts`/`HostStatus` (`~/.wsh/known_hosts`-backed) | **Record unified, policy is not** (wsh #59): both pin `ServerHello.host_fingerprint`, a field no `wsh-server` release populates yet (see [Security](#security)). *When* to trust, prompt, or persist is deliberately left per-implementation -- a browser and a CLI have different UX for "first time seeing this host" |
|
|
309
|
+
| Interactive shell UI | none -- this SDK is a protocol client, not a terminal emulator; pair with xterm.js/ghostty-web | `wsh connect`, `wsh sftp` (line-oriented REPL) | **Deliberately not unified** -- a browser embeds a terminal widget the host page owns; a CLI process owns its own TTY |
|
|
310
|
+
| Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()` | `wsh reverse`, `wsh agent` (persistent, with startup-unit install) | **Wire-unified** (registration, discovery, signed peer records); **daemonization is CLI-only** -- a browser tab cannot be a background OS service |
|
|
311
|
+
| Post-quantum E2E (experimental) | `initiateE2E()` (WebCrypto ML-KEM-768 or `@noble/post-quantum` fallback) | `E2eKeyExchange` (`ml-kem` crate) | **Wire-unified** algorithm and transcript; key material backends differ by platform necessity |
|
|
312
|
+
| Session recording/replay | `SessionRecorder`/`SessionPlayer` (asciicast v2) | none | **JS-only** -- no current Rust consumer needs playback; the format itself (asciicast v2) is not proprietary if one is added later |
|
|
313
|
+
| Self-signed cert pinning | `serverCertificateHashes` (WebTransport option) | N/A -- Rust dials a cert its own TLS stack already trusts, or `--generate-cert`'s self-signed cert out of band | **Deliberately not unified** -- this is a browser-specific WebTransport API shape, not a wire-protocol concept |
|
|
314
|
+
|
|
315
|
+
### Build and test
|
|
316
|
+
|
|
317
|
+
```sh
|
|
318
|
+
cargo build --workspace
|
|
319
|
+
cargo test --workspace
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
The Node-to-Rust cross-implementation tests (`test/rust/*.test.mjs`) spawn
|
|
323
|
+
the real `wsh-server` binary and drive it with this repo's own JS client --
|
|
324
|
+
they're kept out of the default `npm test` glob so JS-only contributors
|
|
325
|
+
never need a Rust toolchain. Build the release binary first (or set
|
|
326
|
+
`WSH_SERVER_BIN` to point at one), then:
|
|
327
|
+
|
|
328
|
+
```sh
|
|
329
|
+
cargo build --release -p wsh-server
|
|
330
|
+
npm run test:rust
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### Codegen: regenerate, don't hand-edit
|
|
334
|
+
|
|
335
|
+
`crates/wsh-core/src/messages.gen.rs` is generated from `spec/wsh-v1.yaml`
|
|
336
|
+
by `spec/codegen.mjs`, alongside the JS (`src/messages.gen.mjs`) and
|
|
337
|
+
Markdown (`spec/wsh-v1.md`) outputs -- all three come from the same schema
|
|
338
|
+
and must never be hand-edited. After changing `wsh-v1.yaml`:
|
|
339
|
+
|
|
340
|
+
```sh
|
|
341
|
+
npm run codegen # regenerate all three outputs
|
|
342
|
+
npm run codegen:check # CI check: fails if any output has drifted from the schema
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
### Release binaries
|
|
346
|
+
|
|
347
|
+
Tagging `rust-vX.Y.Z` (or running the `Release Rust binaries` workflow
|
|
348
|
+
manually with a tag input) builds and publishes prebuilt `wsh-server` and
|
|
349
|
+
`wsh-cli` binaries for four targets to a GitHub Release:
|
|
350
|
+
|
|
351
|
+
- `x86_64-unknown-linux-gnu`
|
|
352
|
+
- `aarch64-apple-darwin`
|
|
353
|
+
- `x86_64-apple-darwin`
|
|
354
|
+
- `i686-unknown-linux-musl` (the target clawser's demo-linux guest image needs)
|
|
355
|
+
|
|
356
|
+
Each target produces two archives, each containing a single binary at the
|
|
357
|
+
archive root:
|
|
358
|
+
|
|
359
|
+
```
|
|
360
|
+
wsh-server-<target>.tar.gz # binary: wsh-server
|
|
361
|
+
wsh-cli-<target>.tar.gz # binary: wsh
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
plus one `SHA256SUMS` file covering every archive in the release. These
|
|
365
|
+
names are a stable contract other repos (clawser) pin and download by --
|
|
366
|
+
do not change them without coordinating downstream.
|
|
367
|
+
|
|
368
|
+
## Security model
|
|
369
|
+
|
|
370
|
+
wsh's protocol draws its line at authentication and message integrity: what
|
|
371
|
+
it guarantees is that a handshake and its messages are who and what they
|
|
372
|
+
claim to be. It does not guarantee confidentiality of every auth method, or
|
|
373
|
+
that a server's identity has actually been pinned yet -- both are called
|
|
374
|
+
out explicitly below rather than left to be discovered.
|
|
176
375
|
|
|
177
|
-
|
|
376
|
+
**What wsh guarantees:**
|
|
377
|
+
|
|
378
|
+
- **Auth transcript binding.** Challenge signatures cover
|
|
178
379
|
`SHA-256("wsh-v1\0" || lp(username) || lp(session_id) || nonce || channel_binding)`,
|
|
179
380
|
so a signature can't be replayed against a different session or relabeled
|
|
180
381
|
to a different username.
|
|
181
|
-
- **Signed peer records
|
|
382
|
+
- **Signed peer records.** Reverse-mode registration is self-signed by the
|
|
182
383
|
peer's identity key (the libp2p RFC 0002/0003 pattern), in a signing
|
|
183
384
|
domain separate from the auth challenge. `listPeers()` verifies every
|
|
184
385
|
entry client-side and reports a `verified` boolean, independent of
|
|
185
386
|
trusting the relay.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
387
|
+
|
|
388
|
+
**What is still yours:**
|
|
389
|
+
|
|
390
|
+
- **Hybrid post-quantum E2E is experimental and not fully wired (in
|
|
391
|
+
progress).** `initiateE2E(sessionId, 'X25519+ML-KEM-768')` combines
|
|
392
|
+
X25519 ECDH with ML-KEM-768 via HKDF-SHA256, preferring native WebCrypto
|
|
393
|
+
ML-KEM-768 (Node 24.7+) with the optional `@noble/post-quantum` pure-JS
|
|
394
|
+
fallback, and falling back to classical X25519 automatically when the
|
|
395
|
+
peer can't do hybrid (check the returned `hybrid` flag). The derived
|
|
396
|
+
AES-256-GCM key is not yet wired to actual frame encryption by default --
|
|
397
|
+
treat `initiateE2E` as key agreement, not confidentiality, unless you've
|
|
398
|
+
separately confirmed the session has sealing enabled.
|
|
399
|
+
- **Host identity (TOFU) has a known, named gap (wsh #59).**
|
|
400
|
+
`ServerHello.host_fingerprint` is the spec's formal host-identity slot:
|
|
401
|
+
the SHA-256 fingerprint of a server's persistent Ed25519 host key, meant
|
|
402
|
+
to be pinned across connections the way SSH pins a host key. **No
|
|
403
|
+
`wsh-server` release populates this field yet** -- minting and
|
|
404
|
+
persisting a server host keypair is a separate, security-sensitive
|
|
405
|
+
feature, deliberately not bundled into this change. Both clients ship
|
|
406
|
+
the store this field is for: `WshKnownHosts` (JS, `localStorage`-backed)
|
|
407
|
+
and `KnownHosts` (Rust, `~/.wsh/known_hosts`-backed) have matching
|
|
408
|
+
verify/add/remove/list semantics. Today, against every real server,
|
|
409
|
+
both report every host as unverified for host-identity purposes; the
|
|
410
|
+
Rust client additionally still falls back to `fingerprints[0]` (an
|
|
411
|
+
*authorized client key* this server happens to have, not this server's
|
|
412
|
+
own identity -- see `ServerHello.fingerprints`' doc comment in
|
|
413
|
+
`spec/wsh-v1.yaml`) with a logged warning, for backward compatibility.
|
|
414
|
+
Do not treat either store as a MITM defense until a server actually
|
|
415
|
+
populates `host_fingerprint`. `serverCertificateHashes` (below) is a
|
|
416
|
+
different, narrower mechanism -- it pins a certificate supplied *per
|
|
417
|
+
connection*, not a persisted record of what a host presented last time.
|
|
418
|
+
- **Password auth has no confidentiality of its own -- it relies entirely
|
|
419
|
+
on the transport.** `AUTH_METHOD.PASSWORD` sends the password as a plain
|
|
420
|
+
string inside the `auth` control message (`client.mjs`'s `#performAuth()`
|
|
421
|
+
calls `sendControl(authMsg({ method: AUTH_METHOD.PASSWORD, password }))`)
|
|
422
|
+
-- there is no client-side hashing, salting, or key derivation before it
|
|
423
|
+
goes on the wire. Its confidentiality is entirely a function of whether
|
|
424
|
+
the underlying connection is encrypted: fine over `wss:`/WebTransport
|
|
425
|
+
against a certificate the client actually trusts (CA-signed, or pinned
|
|
426
|
+
via `serverCertificateHashes`), but sent in the clear if the connection
|
|
427
|
+
is a plain, unencrypted `ws:`. Browsers block that combination from an
|
|
428
|
+
`https:` page as mixed content (see "Pinning a Self-Signed Certificate"
|
|
429
|
+
above), but nothing in this protocol or client stops a non-browser
|
|
430
|
+
caller (Node, the Rust CLI) from dialing `ws://` directly. Prefer pubkey
|
|
431
|
+
auth, or make sure the transport is actually encrypted, before sending a
|
|
432
|
+
password.
|
|
193
433
|
|
|
194
434
|
## Browser Compatibility
|
|
195
435
|
|
|
196
|
-
Requires a browser (or Node.js 24+) with
|
|
436
|
+
Requires a browser (or Node.js 24+) with `TextEncoder`/`TextDecoder`,
|
|
437
|
+
`ReadableStream`/`WritableStream`, `WebSocket` (universal), and:
|
|
438
|
+
|
|
439
|
+
### WebCrypto Ed25519 -- the real floor
|
|
197
440
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
-
|
|
441
|
+
This is what actually gates the library, because pubkey auth is not
|
|
442
|
+
optional to the protocol. Roughly: **Safari 17+, Chrome/Edge 137+,
|
|
443
|
+
Firefox 130+, Node 20+**.
|
|
444
|
+
|
|
445
|
+
There is deliberately **no pure-JS fallback**. A JS Ed25519 needs the
|
|
446
|
+
private scalar as ordinary bytes, which would give up the property
|
|
447
|
+
`WshKeyStore` is built around -- keys are non-extractable `CryptoKey`
|
|
448
|
+
objects by default, so a compromised page can *use* a key but cannot
|
|
449
|
+
exfiltrate it. Trading that away on exactly the oldest, least-patched
|
|
450
|
+
engines is the wrong direction, and doing it silently would be worse.
|
|
451
|
+
|
|
452
|
+
Ask before you commit to pubkey auth:
|
|
453
|
+
|
|
454
|
+
```js
|
|
455
|
+
import { isEd25519Supported } from '@johnhenry/wsh';
|
|
456
|
+
|
|
457
|
+
if (await isEd25519Supported()) {
|
|
458
|
+
await client.connect(url, { username, keyPair });
|
|
459
|
+
} else {
|
|
460
|
+
await client.connect(url, { username, password });
|
|
461
|
+
}
|
|
462
|
+
```
|
|
203
463
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
464
|
+
`isEd25519Supported()` measures by generating a key rather than sniffing a
|
|
465
|
+
version string, and memoizes. `generateKeyPair()` throws with an
|
|
466
|
+
actionable message where support is missing, rather than passing the
|
|
467
|
+
platform's "Unrecognized name" straight through.
|
|
468
|
+
|
|
469
|
+
### WebTransport
|
|
470
|
+
|
|
471
|
+
WebTransport is optional -- the transport ladder falls back to WebSocket.
|
|
472
|
+
It matters if you want `serverCertificateHashes` (see [Pinning a
|
|
473
|
+
Self-Signed Certificate](#pinning-a-self-signed-certificate)), which has
|
|
474
|
+
no WebSocket equivalent.
|
|
475
|
+
|
|
476
|
+
**Safari supports WebTransport, and more of it than Chromium does.**
|
|
477
|
+
That is the opposite of the usual assumption, and the earlier version of
|
|
478
|
+
this section propagated the assumption by listing only Chrome, Edge and
|
|
479
|
+
Firefox. Measured on 2026-09-04, both engines on a `localhost` secure
|
|
480
|
+
context:
|
|
481
|
+
|
|
482
|
+
| | `WebTransport.prototype` members | Ed25519 | X25519 | ML-KEM-768 |
|
|
483
|
+
|---|---|---|---|---|
|
|
484
|
+
| WebKit -- Safari 26.5, iOS 26.5 simulator | **17** | OK | OK | fails (`TypeError`) |
|
|
485
|
+
| Chromium 148, macOS | 10 | OK | OK | fails (`NotSupportedError`) |
|
|
486
|
+
|
|
487
|
+
WebKit's extra seven are `congestionControl`, `reliability`, `draining`,
|
|
488
|
+
`getStats`, `createSendGroup`, and the two
|
|
489
|
+
`anticipatedConcurrentIncoming*Streams` hints; both engines have
|
|
490
|
+
`datagrams`, both bidirectional and unidirectional stream creation, and
|
|
491
|
+
`ready`/`closed`.
|
|
492
|
+
|
|
493
|
+
Approximate first-support versions, which are *not* measured here: Chrome
|
|
494
|
+
and Edge 97, Firefox 114, Safari 26. Below Safari 26, the ladder falls
|
|
495
|
+
back to WebSocket.
|
|
496
|
+
|
|
497
|
+
Both engines accept a `serverCertificateHashes` entry without a
|
|
498
|
+
synchronous throw, but that is weak evidence on its own -- unknown WebIDL
|
|
499
|
+
dictionary members are silently ignored, so acceptance does not prove the
|
|
500
|
+
option is honoured. Confirming it needs a real HTTP/3 server presenting a
|
|
501
|
+
matching short-lived certificate.
|
|
502
|
+
|
|
503
|
+
### ML-KEM-768
|
|
504
|
+
|
|
505
|
+
The hybrid post-quantum path prefers native WebCrypto ML-KEM (Node 24.7+).
|
|
506
|
+
**No browser tested has it** -- it failed on both engines above -- so in a
|
|
507
|
+
browser the optional `@noble/post-quantum` dependency is what actually
|
|
508
|
+
runs, loaded dynamically by `src/mlkem.mjs`. Treat it as required, not
|
|
509
|
+
optional, if you want the hybrid handshake on the web today.
|
|
510
|
+
|
|
511
|
+
## Family
|
|
512
|
+
|
|
513
|
+
wsh isn't just a standalone remote-shell client -- its `WshClient` is the
|
|
514
|
+
designed transport backing one sibling package's network gateway, and
|
|
515
|
+
`WshClient`'s connected/authenticated shape is a drop-in for that sibling's
|
|
516
|
+
injected-client extension point.
|
|
517
|
+
|
|
518
|
+
- **[`@johnhenry/browsermesh-netway`](https://github.com/johnhenry/browsermesh)**
|
|
519
|
+
-- browsermesh-netway's `GatewayBackend` takes a `wshClient` in its
|
|
520
|
+
constructor and proxies every network operation (socket open/send/close)
|
|
521
|
+
through it via `sendControl()`, once the client reaches the
|
|
522
|
+
`'authenticated'` state (`isReady()` checks exactly that). This is a real,
|
|
523
|
+
verified dependency on wsh's client shape -- `GatewayBackend` is
|
|
524
|
+
duck-typed against a `WshClient` instance, not declared as an npm
|
|
525
|
+
dependency -- not a thematic pairing; see
|
|
526
|
+
`packages/browsermesh-netway/src/gateway-backend.mjs` in the browsermesh
|
|
527
|
+
repo. wsh itself has no dependency in the other direction.
|
|
207
528
|
|
|
208
529
|
## License
|
|
209
530
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/wsh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
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",
|
|
7
7
|
"exports": {
|
|
8
|
-
".":
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./src/index.d.ts",
|
|
10
|
+
"import": "./src/index.mjs"
|
|
11
|
+
}
|
|
9
12
|
},
|
|
10
13
|
"types": "./src/index.d.ts",
|
|
11
14
|
"files": [
|
|
@@ -16,8 +19,18 @@
|
|
|
16
19
|
"optionalDependencies": {
|
|
17
20
|
"@noble/post-quantum": "^0.7.0"
|
|
18
21
|
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@fails-components/webtransport": "^1.6.4",
|
|
24
|
+
"@fails-components/webtransport-transport-http3-quiche": "^1.6.4",
|
|
25
|
+
"typescript": "^7.0.0",
|
|
26
|
+
"ws": "^8.21.0"
|
|
27
|
+
},
|
|
19
28
|
"scripts": {
|
|
20
29
|
"test": "node --test test/*.test.mjs",
|
|
30
|
+
"test:rust": "node --test test/rust/*.test.mjs",
|
|
31
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
|
+
"codegen": "node spec/codegen.mjs",
|
|
33
|
+
"codegen:check": "node scripts/codegen-check.mjs",
|
|
21
34
|
"examples": "for f in examples/0*.mjs; do echo \"== $f\" && node \"$f\" || exit 1; done",
|
|
22
35
|
"example:01": "node examples/01-frames-survive-fragmented-transport.mjs",
|
|
23
36
|
"example:02": "node examples/02-tampered-challenge-fails-authentication.mjs",
|
|
@@ -45,7 +58,7 @@
|
|
|
45
58
|
},
|
|
46
59
|
"author": "John Henry",
|
|
47
60
|
"engines": {
|
|
48
|
-
"node": ">=
|
|
61
|
+
"node": ">=26.0.0"
|
|
49
62
|
},
|
|
50
63
|
"homepage": "https://opensource.johnhenry.me/wsh/"
|
|
51
64
|
}
|
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 ───────────────────────────────────────────────────
|