@johnhenry/wsh 0.17.1 → 0.19.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
@@ -16,7 +16,7 @@ Full documentation: [opensource.johnhenry.me/wsh](https://opensource.johnhenry.m
16
16
 
17
17
  Browser-native remote command execution over WebTransport/WebSocket with Ed25519 authentication.
18
18
 
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.
19
+ wsh is a pure-JS client library (with an optional Node server at `@johnhenry/wsh/server`) 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.
20
20
 
21
21
  ## Contents
22
22
 
@@ -25,6 +25,7 @@ wsh is a pure-JS client library that connects browsers to remote shells. It impl
25
25
  - [Wire Protocol: QMux](#wire-protocol-qmux)
26
26
  - [Quick Start](#quick-start)
27
27
  - [One-Shot Command Execution](#one-shot-command-execution)
28
+ - [Node Server](#node-server)
28
29
  - [Attach and Resume](#attach-and-resume)
29
30
  - [Pinning a Self-Signed Certificate](#pinning-a-self-signed-certificate)
30
31
  - [API Overview](#api-overview)
@@ -145,6 +146,153 @@ console.log(new TextDecoder().decode(stdout));
145
146
  console.log('Exit code:', exitCode);
146
147
  ```
147
148
 
149
+ `exec` and `openSession({ type: 'exec' })` need no extra step to receive
150
+ output. Older hosts only discover the client-opened data stream once a byte
151
+ arrives on it, so against a host that does not advertise `stream-announce`
152
+ the client writes a one-byte primer for you (a host that strips it, as those
153
+ hosts do). Pass `primer: false` to never send it.
154
+
155
+ ## Node Server
156
+
157
+ `@johnhenry/wsh/server` is a Node host for the same protocol, over WebSocket
158
+ (QMux). It is a separate subpath -- the package root stays browser-safe and
159
+ never imports it -- and needs the optional peer dependency `ws`
160
+ (`npm install ws`).
161
+
162
+ ```js
163
+ import { createWshServer } from '@johnhenry/wsh/server';
164
+ import { readFileSync } from 'node:fs';
165
+
166
+ const server = createWshServer({
167
+ host: '127.0.0.1',
168
+ port: 4422, // 0 = pick a free port
169
+ auth: { authorizedKeys: readFileSync('authorized_keys', 'utf8') }, // ssh-ed25519 lines
170
+ hostKey: { file: '/etc/wsh/host_key' }, // advertise a pinnable host identity (created 0600 on first start)
171
+ exec: true, // opt in: run commands via /bin/sh
172
+ fs: { root: '/srv/share', readOnly: false }, // opt in: list/stat/read/write/rename/mkdir/remove/upload/download
173
+ // pty: { spawn: nodePty.spawn }, // opt in: bring your own node-pty
174
+ });
175
+ const { port } = await server.listen(); // later: server.address(), await server.close()
176
+ ```
177
+
178
+ Everything that touches the machine -- `exec`, `pty`, `fs` -- is **off unless
179
+ you pass it**, and with no `auth` every connection is refused. `auth` is
180
+ `{ authorizedKeys, authorize, password, rateLimit }` (a key must be on the
181
+ list and pass `authorize({ username, fingerprint, publicKey })` when both are
182
+ given) or just an `authorize` function. `exec` can also be `{ cwd, env,
183
+ shell, timeoutMs, clientEnv }` or a custom `run(command, io)` for a
184
+ restricted host with no shell. `fs.root` confines every path: `..`, absolute
185
+ paths and symlinks pointing out of it are refused -- for `write` and `rename`
186
+ (both paths) exactly as for `read`.
187
+
188
+ Not implemented (each has a tracking issue): WebTransport, relay/reverse
189
+ mode, attach/resume of server-side sessions, MCP.
190
+
191
+ ### Host key (fingerprint / TOFU)
192
+
193
+ Give the server an identity and the stock client can pin it:
194
+
195
+ ```js
196
+ // server
197
+ const server = createWshServer({ auth, hostKey: { file: '/etc/wsh/host_key' } });
198
+ await server.listen();
199
+ server.hostKey(); // { fingerprint, publicKey, openssh } -- publish this out of band
200
+
201
+ // client
202
+ import { WshClient, WshKnownHosts, HostKeyError } from '@johnhenry/wsh';
203
+ const client = new WshClient();
204
+ await client.connect(url, { username, keyPair, expectHostKey: fingerprint }); // pin; refuses on mismatch
205
+ // or trust-on-first-use, remembered in a store:
206
+ await client.connect(url, { username, keyPair, knownHosts: new WshKnownHosts(), trustOnFirstUse: true });
207
+ client.hostKey; // { fingerprint, publicKey, openssh, status: 'pinned' | 'known' | 'unknown' | 'unpinned' }
208
+ client.onHostKey = (hk) => confirm(`Trust ${hk.fingerprint}?`); // or per call: connect(..., { onHostKey })
209
+ ```
210
+
211
+ - `hostKey` is `{ file }` (a PKCS#8 PEM, created mode 0600 on first start --
212
+ use this), a `CryptoKeyPair`, or `true` (a fresh key every start: every TOFU
213
+ client will see "changed" after a restart; tests and demos only).
214
+ - `expectHostKey` takes a hex fingerprint (`sha256:` prefix fine), raw 32-byte
215
+ key, or an `ssh-ed25519 AAAA...` line. With `knownHosts`, a **changed** key is
216
+ always refused (and never overwrites the pin); an **unseen** host is refused
217
+ unless `trustOnFirstUse` is set or `onHostKey` accepts it. Either option also
218
+ refuses a host that presents no key at all. Refusals are `HostKeyError` with
219
+ a `code` (`HOST_KEY_MISSING | _INVALID | _MISMATCH | _UNKNOWN | _REJECTED`),
220
+ thrown **before any credential -- signature or password -- is sent**.
221
+ - `WshClient.exec()` and `connectReverse()` accept the same options.
222
+ - `WshKnownHosts` defaults to `localStorage`, which in Node is not a persistent file store: for a real TOFU file pass `new WshKnownHosts({ storage })` with a `getItem`/`setItem`/`removeItem` object you back with a file.
223
+ - Wire shape: the spec's `ServerHello.host_fingerprint` is populated, and
224
+ because a bare fingerprint proves nothing (anyone can repeat it) the key and
225
+ a signature ride in `features` strings: the client sends `host-key-nonce:<hex>`
226
+ in `Hello`; the server answers `host-key:<hex>` and `host-key-sig:<hex>`
227
+ (Ed25519 over a tag, the session id, the client's nonce and the username).
228
+ The fresh nonce makes the proof unreplayable. Hosts that don't know these
229
+ strings ignore them (the Rust `wsh-server` advertises no key, so a pin
230
+ against it is refused with `HOST_KEY_MISSING`, never silently skipped).
231
+ - **What it does not give you:** the proof authenticates the ServerHello, not
232
+ the byte stream. Over plain `ws://` an active attacker can relay a genuine
233
+ ServerHello and then read or alter the rest. Pinning keeps you from talking
234
+ to the wrong host; confidentiality still needs `wss://` or a link you trust.
235
+
236
+ ### Password auth
237
+
238
+ ```js
239
+ createWshServer({
240
+ auth: {
241
+ password: async (username, password) => verifyAgainstYourHashes(username, password),
242
+ // authorizedKeys / authorize may be given too: both methods are then served.
243
+ rateLimit: { maxFailures: 5, windowMs: 60_000, lockoutMs: 60_000, failureDelayMs: 250 },
244
+ },
245
+ });
246
+ await client.connect(url, { username, password });
247
+ ```
248
+
249
+ The wire already has it (`Hello.auth_method: 'password'` then `Auth{ method:
250
+ 'password', password }`, no challenge), so no extension is involved -- and
251
+ that is also its limit: the password travels as plain text inside the
252
+ connection, so serve password logins over `wss://` (TLS-terminating proxy)
253
+ or a link you trust, and pin the host key so it is only ever sent to the right
254
+ host. Use a constant-time comparison against stored hashes in the callback,
255
+ never `===` on plaintext. A host with only `password` refuses key logins and
256
+ vice versa; with neither configured every connection is refused.
257
+
258
+ Failures are throttled per peer address (override with `rateLimit.key`, e.g.
259
+ read `x-forwarded-for` from `headers` behind a proxy): each connection gets one
260
+ guess, a failed one is answered after `failureDelayMs`, and after
261
+ `maxFailures` in `windowMs` the caller is locked out for `lockoutMs` -- the
262
+ callback is not even consulted during lockout, so the right password does not
263
+ get through either. The counter is in-process memory and per server.
264
+
265
+ ### Files: write and rename
266
+
267
+ `client.fileWrite(path, data, offset?)` and `client.fileRename(from, to)` work
268
+ against the server's `fs` (earlier versions of the client sent neither the
269
+ bytes nor the new path). Without `offset`, `fileWrite` replaces the file
270
+ (creating it); with one, it writes in place at that byte offset without
271
+ truncating. `fileRename` refuses to overwrite an existing destination, to move
272
+ a directory into itself, or to touch the root. Both honour `readOnly` and
273
+ `maxFileBytes`, and a failure comes back as `success: false` with an
274
+ `error_message`.
275
+
276
+ `FileOp` has no field for the bytes or the destination, so both use
277
+ spec-conformant frames only: the `FileOp` is followed by `FileChunk` frame(s) on
278
+ the same channel id (for `rename`, one chunk holding the UTF-8 destination).
279
+ The host advertises `file-write` / `file-rename` in `ServerHello`; the client
280
+ throws instead of sending to a host without them (the Rust `wsh-server`
281
+ refuses these ops).
282
+
283
+ ### Compatibility: announce vs. primer
284
+
285
+ An exec session's output flows on a second, client-opened stream, and a QMux
286
+ stream is invisible on the wire until its first byte. Two mechanisms exist for
287
+ the host to find it:
288
+
289
+ | Client | Host | What happens |
290
+ |---|---|---|
291
+ | Current stock client | `@johnhenry/wsh/server` (advertises `stream-announce`) | The client's transport announces the stream with an empty STREAM frame. **No primer is written.** |
292
+ | Current stock client | Host without `stream-announce` (e.g. the Rust `wsh-server`, older vendored hosts) | The client writes a one-byte primer (`0x00`) on the new stream so the host sees it; that host strips it. `primer: false` disables this (then such a host never binds the stream and the output is lost). |
293
+ | A client that primes anyway (older stock client, `primer` forced, other implementations) | `@johnhenry/wsh/server` | **The leading `0x00` is dropped:** if the first data on an exec stream is exactly one `0x00` byte it is discarded, never forwarded to the process's stdin. Only that first chunk is inspected; a later `0x00`, or a first chunk that is longer than one byte, is delivered untouched. The cost: a client whose genuine first stdin chunk is a lone NUL loses it. |
294
+ | A client that neither announces nor primes | `@johnhenry/wsh/server` | The host waits `bindTimeoutMs` (default 3000) for the stream, then drops the output and logs it. |
295
+
148
296
  ## Attach and Resume
149
297
 
150
298
  Opening a PTY/exec session returns a session-scoped credential alongside the channel:
@@ -249,6 +397,7 @@ so options the platform gains later need no change here.
249
397
  | `SessionRecorder` | Record PTY I/O with timestamps (own schema, not asciicast v2) |
250
398
  | `SessionPlayer` | Replay recordings with original timing |
251
399
  | `generateKeyPair()` | Create Ed25519 key pair via Web Crypto |
400
+ | `sign(privateKey, message)` / `verify(publicKey, signature, message)` | Raw Ed25519 sign / verify. This positional order (WebCrypto's) is the one canonical form across wsh, raijin and browsermesh |
252
401
  | `signChallenge()` | Build transcript + sign for auth handshake |
253
402
  | `signPeerRecord()` / `verifyPeerRecord()` | Sign / verify reverse-mode peer records |
254
403
  | `fingerprint()` | SHA-256 hex fingerprint of a public key |
@@ -305,7 +454,7 @@ than left to be found.
305
454
  | Upload/download | `WshClient.upload()`/`.download()` | `file_transfer::upload()`/`download()`, `wsh scp` | **Wire-unified**: `FileChunk` over a `'file'`-kind channel (wsh #13) |
306
455
  | 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
456
  | 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" |
457
+ | Host identity / TOFU | `WshKnownHosts` (localStorage-backed) | `KnownHosts`/`HostStatus` (`~/.wsh/known_hosts`-backed) | **Record unified, policy is not** (wsh #59): both pin `ServerHello.host_fingerprint`, which `@johnhenry/wsh/server` populates (with a proof of possession, [Host key](#host-key-fingerprint--tofu)) but no Rust `wsh-server` release does yet (see [Security](#security-model)). *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
458
  | 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
459
  | 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
460
  | 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 |
@@ -399,8 +548,11 @@ out explicitly below rather than left to be discovered.
399
548
  - **Host identity (TOFU) has a known, named gap (wsh #59).**
400
549
  `ServerHello.host_fingerprint` is the spec's formal host-identity slot:
401
550
  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
551
+ to be pinned across connections the way SSH pins a host key. **As of 0.19
552
+ `@johnhenry/wsh/server` populates it** (with a signed proof of possession;
553
+ see [Host key](#host-key-fingerprint--tofu)) and the JS client pins it
554
+ (`expectHostKey`, `knownHosts`) -- but **no Rust `wsh-server` release does
555
+ yet**, and the paragraph below still describes that Rust-side gap -- minting and
404
556
  persisting a server host keypair is a separate, security-sensitive
405
557
  feature, deliberately not bundled into this change. Both clients ship
406
558
  the store this field is for: `WshKnownHosts` (JS, `localStorage`-backed)
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.17.1",
4
- "description": "Web Shell — browser-native remote command execution over WebTransport and WebSocket with Ed25519 authentication",
3
+ "version": "0.19.0",
4
+ "description": "Web Shell \u2014 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
9
  "types": "./src/index.d.ts",
10
10
  "import": "./src/index.mjs"
11
+ },
12
+ "./server": {
13
+ "types": "./src/server/index.d.ts",
14
+ "import": "./src/server/index.mjs"
11
15
  }
12
16
  },
13
17
  "types": "./src/index.d.ts",
@@ -19,6 +23,14 @@
19
23
  "optionalDependencies": {
20
24
  "@noble/post-quantum": "^0.7.0"
21
25
  },
26
+ "peerDependencies": {
27
+ "ws": "^8.0.0"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "ws": {
31
+ "optional": true
32
+ }
33
+ },
22
34
  "devDependencies": {
23
35
  "@fails-components/webtransport": "^1.6.4",
24
36
  "@fails-components/webtransport-transport-http3-quiche": "^1.6.4",