@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 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` and restarted at 0.0.0 on import into the
7
- > @johnhenry family — a new name and era, not a maturity signal.
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
+ [![npm version](https://img.shields.io/npm/v/%40johnhenry%2Fwsh.svg)](https://www.npmjs.com/package/@johnhenry/wsh)
12
+ [![CI](https://github.com/johnhenry/wsh/actions/workflows/ci.yml/badge.svg)](https://github.com/johnhenry/wsh/actions/workflows/ci.yml)
13
+ [![license](https://img.shields.io/npm/l/%40johnhenry%2Fwsh.svg)](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 compatible recording and playback with seek/pause/resume
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
- - **95 message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, and more
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` | 95 message type constants (hex opcodes) |
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
- ## Security
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
- - **Auth transcript binding** -- challenge signatures cover
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** -- reverse-mode registration is self-signed by the
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
- - **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.
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
- - Web Crypto API with Ed25519 support
199
- - WebSocket (all browsers)
200
- - WebTransport (Chrome 97+, Edge 97+, Firefox 114+)
201
- - TextEncoder/TextDecoder
202
- - ReadableStream/WritableStream
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
- 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.
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.16.1",
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
- ".": "./src/index.mjs"
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": ">=24.0.0"
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
- return crypto.subtle.generateKey('Ed25519', extractable, ['sign', 'verify']);
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 ───────────────────────────────────────────────────