@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 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
- - Web Crypto API with Ed25519 support
199
- - WebSocket (all browsers)
200
- - WebTransport (Chrome 97+, Edge 97+, Firefox 114+)
201
- - TextEncoder/TextDecoder
202
- - ReadableStream/WritableStream
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
- 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.
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.16.1",
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
- 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 ───────────────────────────────────────────────────