@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 +156 -4
- package/package.json +14 -2
- package/src/client.mjs +257 -25
- package/src/host-key.mjs +97 -0
- package/src/index.d.ts +94 -6
- package/src/index.mjs +2 -1
- package/src/qmux-connection.mjs +21 -0
- package/src/server/auth.mjs +132 -0
- package/src/server/connection.mjs +493 -0
- package/src/server/exec.mjs +60 -0
- package/src/server/fs.mjs +174 -0
- package/src/server/host-key.mjs +66 -0
- package/src/server/index.d.ts +168 -0
- package/src/server/index.mjs +118 -0
- package/src/server/pty.mjs +25 -0
- package/src/transport-ws.mjs +4 -0
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
|
|
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. **
|
|
403
|
-
|
|
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.
|
|
4
|
-
"description": "Web Shell
|
|
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",
|