@johnhenry/wsh 0.17.0 → 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 +141 -18
- package/package.json +8 -3
- package/src/client.mjs +40 -0
- package/src/file-transfer.mjs +45 -156
- package/src/index.d.ts +125 -2
- package/src/index.mjs +4 -0
- package/src/known-hosts.mjs +139 -0
- package/src/messages.gen.mjs +22 -9
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
|
|
@@ -34,9 +59,22 @@ Or via CDN:
|
|
|
34
59
|
- **Reverse mode** -- register as a peer (via a signed peer record) and accept incoming connections through a relay
|
|
35
60
|
- **File transfer** -- scp-like upload/download as `FileChunk` control messages in 64KB chunks
|
|
36
61
|
- **MCP bridge** -- discover and invoke remote MCP tools through the control channel
|
|
37
|
-
- **Session recording** -- asciicast v2
|
|
62
|
+
- **Session recording** -- own JSON schema (not asciicast v2 -- see note below) recording and playback with seek/pause/resume
|
|
38
63
|
- **Key management** -- IndexedDB storage with OPFS encrypted backup (PBKDF2 + AES-256-GCM)
|
|
39
|
-
- **
|
|
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.
|
|
40
78
|
|
|
41
79
|
## Wire Protocol: QMux
|
|
42
80
|
|
|
@@ -208,7 +246,7 @@ so options the platform gains later need no change here.
|
|
|
208
246
|
| `WshKeyStore` | Ed25519 key management via IndexedDB + OPFS encrypted backup |
|
|
209
247
|
| `WshFileTransfer` | File upload/download over dedicated streams |
|
|
210
248
|
| `WshMcpBridge` | Remote MCP tool discovery and invocation |
|
|
211
|
-
| `SessionRecorder` | Record PTY I/O with timestamps (asciicast v2) |
|
|
249
|
+
| `SessionRecorder` | Record PTY I/O with timestamps (own schema, not asciicast v2) |
|
|
212
250
|
| `SessionPlayer` | Replay recordings with original timing |
|
|
213
251
|
| `generateKeyPair()` | Create Ed25519 key pair via Web Crypto |
|
|
214
252
|
| `signChallenge()` | Build transcript + sign for auth handshake |
|
|
@@ -221,7 +259,7 @@ so options the platform gains later need no change here.
|
|
|
221
259
|
|
|
222
260
|
| Export | Description |
|
|
223
261
|
|--------|-------------|
|
|
224
|
-
| `MSG` |
|
|
262
|
+
| `MSG` | 97 message type constants (hex opcodes) |
|
|
225
263
|
| `CHANNEL_KIND` | Channel types: `pty`, `exec`, `meta`, `file`, `tcp`, `udp`, `job` |
|
|
226
264
|
| `AUTH_METHOD` | Auth methods: `pubkey`, `password` |
|
|
227
265
|
| `cborEncode` / `cborDecode` | CBOR codec (maps, arrays, strings, ints, bytes, bools, null, floats) |
|
|
@@ -254,6 +292,26 @@ Four crates, workspace-versioned at `0.1.0`:
|
|
|
254
292
|
| `wsh-cli` | binary (`wsh`) | SSH-like CLI: connect, exec, scp-style copy, reverse tunnels, key management |
|
|
255
293
|
| `wsh-server` | binary (`wsh-server`) | Server: WebTransport/WebSocket listener, real PTY sessions (`portable-pty`), relay/reverse-connect, WISP bridging |
|
|
256
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
|
+
|
|
257
315
|
### Build and test
|
|
258
316
|
|
|
259
317
|
```sh
|
|
@@ -307,24 +365,71 @@ plus one `SHA256SUMS` file covering every archive in the release. These
|
|
|
307
365
|
names are a stable contract other repos (clawser) pin and download by --
|
|
308
366
|
do not change them without coordinating downstream.
|
|
309
367
|
|
|
310
|
-
## Security
|
|
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.
|
|
375
|
+
|
|
376
|
+
**What wsh guarantees:**
|
|
311
377
|
|
|
312
|
-
- **Auth transcript binding
|
|
378
|
+
- **Auth transcript binding.** Challenge signatures cover
|
|
313
379
|
`SHA-256("wsh-v1\0" || lp(username) || lp(session_id) || nonce || channel_binding)`,
|
|
314
380
|
so a signature can't be replayed against a different session or relabeled
|
|
315
381
|
to a different username.
|
|
316
|
-
- **Signed peer records
|
|
382
|
+
- **Signed peer records.** Reverse-mode registration is self-signed by the
|
|
317
383
|
peer's identity key (the libp2p RFC 0002/0003 pattern), in a signing
|
|
318
384
|
domain separate from the auth challenge. `listPeers()` verifies every
|
|
319
385
|
entry client-side and reports a `verified` boolean, independent of
|
|
320
386
|
trusting the relay.
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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.
|
|
328
433
|
|
|
329
434
|
## Browser Compatibility
|
|
330
435
|
|
|
@@ -403,6 +508,24 @@ browser the optional `@noble/post-quantum` dependency is what actually
|
|
|
403
508
|
runs, loaded dynamically by `src/mlkem.mjs`. Treat it as required, not
|
|
404
509
|
optional, if you want the hybrid handshake on the web today.
|
|
405
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.
|
|
528
|
+
|
|
406
529
|
## License
|
|
407
530
|
|
|
408
531
|
MIT
|
package/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/wsh",
|
|
3
|
-
"version": "0.17.
|
|
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": [
|
|
@@ -19,11 +22,13 @@
|
|
|
19
22
|
"devDependencies": {
|
|
20
23
|
"@fails-components/webtransport": "^1.6.4",
|
|
21
24
|
"@fails-components/webtransport-transport-http3-quiche": "^1.6.4",
|
|
25
|
+
"typescript": "^7.0.0",
|
|
22
26
|
"ws": "^8.21.0"
|
|
23
27
|
},
|
|
24
28
|
"scripts": {
|
|
25
29
|
"test": "node --test test/*.test.mjs",
|
|
26
30
|
"test:rust": "node --test test/rust/*.test.mjs",
|
|
31
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
27
32
|
"codegen": "node spec/codegen.mjs",
|
|
28
33
|
"codegen:check": "node scripts/codegen-check.mjs",
|
|
29
34
|
"examples": "for f in examples/0*.mjs; do echo \"== $f\" && node \"$f\" || exit 1; done",
|
|
@@ -53,7 +58,7 @@
|
|
|
53
58
|
},
|
|
54
59
|
"author": "John Henry",
|
|
55
60
|
"engines": {
|
|
56
|
-
"node": ">=
|
|
61
|
+
"node": ">=26.0.0"
|
|
57
62
|
},
|
|
58
63
|
"homepage": "https://opensource.johnhenry.me/wsh/"
|
|
59
64
|
}
|
package/src/client.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
keyExchange as keyExchangeMsg,
|
|
30
30
|
fileOp as fileOpMsg,
|
|
31
31
|
fileChunk as fileChunkMsg,
|
|
32
|
+
authorizedKeyAdd as authorizedKeyAddMsg,
|
|
32
33
|
policyEval as policyEvalMsg, policyUpdate as policyUpdateMsg,
|
|
33
34
|
detach as detachMsg,
|
|
34
35
|
sessionListRequest as sessionListRequestMsg,
|
|
@@ -1738,6 +1739,45 @@ export class WshClient {
|
|
|
1738
1739
|
/** Rename a remote file or directory. */
|
|
1739
1740
|
async fileRename(oldPath, newPath, timeout) { return this.fileOperation('rename', oldPath, {}, timeout); }
|
|
1740
1741
|
|
|
1742
|
+
// ── Authorized-key management (wsh #59) ───────────────────────────
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* Install a raw 32-byte Ed25519 public key into the remote host's
|
|
1746
|
+
* ~/.wsh/authorized_keys via the AuthorizedKeyAdd protocol message.
|
|
1747
|
+
*
|
|
1748
|
+
* Replaces `wsh copy-id`'s CLI-only, shell-command-built approach
|
|
1749
|
+
* (`crates/wsh-cli/src/commands/copy_id.rs`) with a real protocol
|
|
1750
|
+
* message every implementation can call -- including this one, the
|
|
1751
|
+
* browser SDK, which previously had no way to install a key at all
|
|
1752
|
+
* (no shell channel to build a command string for). See copy_id.rs's
|
|
1753
|
+
* doc comment for the full security reasoning: an authenticated
|
|
1754
|
+
* connection already has unrestricted exec/file access to this exact
|
|
1755
|
+
* file, so this adds no new privilege boundary.
|
|
1756
|
+
*
|
|
1757
|
+
* Idempotent: installing an already-present key resolves
|
|
1758
|
+
* `{ added: false }` rather than duplicating the line or erroring.
|
|
1759
|
+
*
|
|
1760
|
+
* @param {Uint8Array} publicKeyRaw - Raw 32-byte Ed25519 public key
|
|
1761
|
+
* @param {string} [comment] - Trailing comment for the authorized_keys line
|
|
1762
|
+
* @param {number} [timeout=10000]
|
|
1763
|
+
* @returns {Promise<{ added: boolean }>}
|
|
1764
|
+
*/
|
|
1765
|
+
async addAuthorizedKey(publicKeyRaw, comment, timeout = DEFAULT_OPEN_TIMEOUT) {
|
|
1766
|
+
this.#assertAuthenticated('addAuthorizedKey');
|
|
1767
|
+
await this.#transport.sendControl(
|
|
1768
|
+
authorizedKeyAddMsg({ publicKey: publicKeyRaw, comment })
|
|
1769
|
+
);
|
|
1770
|
+
const result = await this.#waitForMessage(
|
|
1771
|
+
[MSG.AUTHORIZED_KEY_RESULT],
|
|
1772
|
+
timeout,
|
|
1773
|
+
'Timed out waiting for authorized-key-add result'
|
|
1774
|
+
);
|
|
1775
|
+
if (!result.success) {
|
|
1776
|
+
throw new Error(result.error_message || 'key installation refused');
|
|
1777
|
+
}
|
|
1778
|
+
return { added: !!result.added };
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1741
1781
|
// ── Policy Engine ─────────────────────────────────────────────────
|
|
1742
1782
|
|
|
1743
1783
|
/**
|
package/src/file-transfer.mjs
CHANGED
|
@@ -3,38 +3,36 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Uploads and downloads delegate to the underlying client's upload()/
|
|
5
5
|
* download() (FileChunk control messages over a 'file'-kind channel —
|
|
6
|
-
* see client.mjs). list()
|
|
7
|
-
* a
|
|
6
|
+
* see client.mjs). list() (wsh #59) delegates to the client's fileList(),
|
|
7
|
+
* which sends a structured FileOp{op:"list"} and reads the typed
|
|
8
|
+
* FileResult.entries -- the same wire path upload/download already use
|
|
9
|
+
* for moving bytes, now extended to directory listings.
|
|
10
|
+
*
|
|
11
|
+
* Before wsh #59, list() ran `ls -la` over a plain exec channel and
|
|
12
|
+
* parsed the text output client-side -- a private, JS-only convention
|
|
13
|
+
* the Rust client had no equivalent of at all (see wsh #58/#59, and the
|
|
14
|
+
* FileEntry nested type in spec/wsh-v1.yaml this now shares with Rust).
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
|
-
import { MSG, open, CHANNEL_KIND } from './messages.mjs';
|
|
11
|
-
import { waitForControlMessage } from './control-listener.mjs';
|
|
12
|
-
|
|
13
17
|
/** Default timeout for waiting on control messages (30 seconds). */
|
|
14
18
|
const RESPONSE_TIMEOUT_MS = 30_000;
|
|
15
19
|
|
|
16
20
|
export class WshFileTransfer {
|
|
17
21
|
/**
|
|
18
22
|
* @type {{
|
|
19
|
-
* upload: function, download: function,
|
|
20
|
-
* sendControl: function, openStream: function, onControl: function,
|
|
23
|
+
* upload: function, download: function, fileList: function,
|
|
21
24
|
* }}
|
|
22
25
|
*/
|
|
23
26
|
#client;
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
|
-
* @param {object} client - A WshClient (or any object exposing
|
|
27
|
-
* download()
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* (or a method to add a listener — see _waitForMessage)
|
|
34
|
-
*
|
|
35
|
-
* Only `list()` needs the control-channel members, so they are checked
|
|
36
|
-
* there rather than here: uploading and downloading through a client
|
|
37
|
-
* that has `upload()`/`download()` and nothing else stays valid.
|
|
29
|
+
* @param {object} client - A WshClient (or any object exposing the same
|
|
30
|
+
* methods used below). `upload()`/`download()` are used directly for
|
|
31
|
+
* file transfer; `list()` uses `fileList()` (the structured
|
|
32
|
+
* FileOp/FileResult request-response helper already on WshClient).
|
|
33
|
+
* Each method checks for the members it specifically needs, so a
|
|
34
|
+
* client exposing only `upload()`/`download()` (and nothing else)
|
|
35
|
+
* stays valid for those two calls.
|
|
38
36
|
*/
|
|
39
37
|
constructor(client) {
|
|
40
38
|
if (!client) throw new Error('WshFileTransfer requires a client');
|
|
@@ -97,156 +95,47 @@ export class WshFileTransfer {
|
|
|
97
95
|
}
|
|
98
96
|
|
|
99
97
|
/**
|
|
100
|
-
* List files at a remote path
|
|
98
|
+
* List files at a remote path over the structured file channel (wsh #59):
|
|
99
|
+
* sends `FileOp{op:"list"}` via the client's `fileList()` and reads the
|
|
100
|
+
* typed `FileResult.entries` -- the same `FileEntry` shape
|
|
101
|
+
* `crates/wsh-core`'s Rust client is generated from, so a symlink can't
|
|
102
|
+
* be reported as a plain file here and correctly here-and-there.
|
|
101
103
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
+
* A refusal (unimplemented op, unauthorized fs capability, no such
|
|
105
|
+
* directory) throws -- it is never coerced into an empty array, so a
|
|
106
|
+
* caller can't mistake "refused" for "empty directory" (wsh #58).
|
|
104
107
|
*
|
|
105
108
|
* @param {string} remotePath - Directory path on the remote host
|
|
106
|
-
* @
|
|
109
|
+
* @param {object} [opts]
|
|
110
|
+
* @param {number} [opts.timeout] - Timeout in ms for the server response
|
|
111
|
+
* @returns {Promise<Array<{
|
|
112
|
+
* name: string, size: number, modified: Date,
|
|
113
|
+
* type: 'file'|'directory'|'symlink'|'device'|'pipe'|'socket',
|
|
114
|
+
* symlinkTarget?: string,
|
|
115
|
+
* }>>}
|
|
107
116
|
*/
|
|
108
|
-
async list(remotePath) {
|
|
117
|
+
async list(remotePath, { timeout = RESPONSE_TIMEOUT_MS } = {}) {
|
|
109
118
|
if (!remotePath || typeof remotePath !== 'string') {
|
|
110
119
|
throw new Error('remotePath is required');
|
|
111
120
|
}
|
|
112
|
-
if (typeof this.#client.
|
|
121
|
+
if (typeof this.#client.fileList !== 'function') {
|
|
113
122
|
throw new TypeError(
|
|
114
|
-
'WshFileTransfer.list() requires a client exposing
|
|
123
|
+
'WshFileTransfer.list() requires a client exposing fileList() (e.g. WshClient)'
|
|
115
124
|
);
|
|
116
125
|
}
|
|
117
126
|
|
|
118
|
-
|
|
119
|
-
const openMsg = open({
|
|
120
|
-
kind: CHANNEL_KIND.EXEC,
|
|
121
|
-
command: `ls -la ${this._shellEscape(remotePath)}`,
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
await this.#client.sendControl(openMsg);
|
|
125
|
-
|
|
126
|
-
const response = await this._waitForMessage(
|
|
127
|
-
(msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
|
|
128
|
-
RESPONSE_TIMEOUT_MS
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
if (response.type === MSG.OPEN_FAIL) {
|
|
132
|
-
throw new Error(`List failed: ${response.reason || 'unknown reason'}`);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Read the output stream
|
|
136
|
-
const stream = await this.#client.openStream();
|
|
137
|
-
const reader = stream.readable.getReader();
|
|
138
|
-
const chunks = [];
|
|
139
|
-
|
|
140
|
-
try {
|
|
141
|
-
while (true) {
|
|
142
|
-
const { done, value } = await reader.read();
|
|
143
|
-
if (done) break;
|
|
144
|
-
chunks.push(value);
|
|
145
|
-
}
|
|
146
|
-
} finally {
|
|
147
|
-
reader.releaseLock();
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const output = new TextDecoder().decode(this._concatChunks(chunks));
|
|
151
|
-
return this._parseLsOutput(output);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// ── Internal helpers ──────────────────────────────────────────────
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Wait for a control message matching a predicate.
|
|
158
|
-
*
|
|
159
|
-
* Temporarily hooks into the client's control message flow and resolves
|
|
160
|
-
* when a matching message arrives (or rejects on timeout).
|
|
161
|
-
*
|
|
162
|
-
* @param {function(object): boolean} predicate
|
|
163
|
-
* @param {number} timeoutMs
|
|
164
|
-
* @returns {Promise<object>}
|
|
165
|
-
*/
|
|
166
|
-
_waitForMessage(predicate, timeoutMs) {
|
|
167
|
-
return waitForControlMessage(this.#client, predicate, timeoutMs, 'File transfer response');
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Concatenate an array of Uint8Arrays into one.
|
|
172
|
-
*
|
|
173
|
-
* @param {Uint8Array[]} chunks
|
|
174
|
-
* @param {number} [totalLength] - Pre-computed total, avoids re-summing
|
|
175
|
-
* @returns {Uint8Array}
|
|
176
|
-
*/
|
|
177
|
-
_concatChunks(chunks, totalLength) {
|
|
178
|
-
if (chunks.length === 0) return new Uint8Array(0);
|
|
179
|
-
if (chunks.length === 1) return chunks[0];
|
|
180
|
-
|
|
181
|
-
const total = totalLength ?? chunks.reduce((sum, c) => sum + c.length, 0);
|
|
182
|
-
const result = new Uint8Array(total);
|
|
183
|
-
let offset = 0;
|
|
184
|
-
for (const chunk of chunks) {
|
|
185
|
-
result.set(chunk, offset);
|
|
186
|
-
offset += chunk.length;
|
|
187
|
-
}
|
|
188
|
-
return result;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Parse the output of `ls -la` into structured entries.
|
|
193
|
-
*
|
|
194
|
-
* Expected format per line:
|
|
195
|
-
* drwxr-xr-x 2 user group 4096 Jan 15 12:00 dirname
|
|
196
|
-
* -rw-r--r-- 1 user group 12345 Jan 15 12:00 filename.txt
|
|
197
|
-
*
|
|
198
|
-
* @param {string} output
|
|
199
|
-
* @returns {Array<{ name: string, size: number, modified: string, type: string }>}
|
|
200
|
-
*/
|
|
201
|
-
_parseLsOutput(output) {
|
|
202
|
-
const lines = output.split('\n').filter((l) => l.trim());
|
|
203
|
-
const entries = [];
|
|
204
|
-
|
|
205
|
-
for (const line of lines) {
|
|
206
|
-
// Skip the "total" header line
|
|
207
|
-
if (line.startsWith('total ')) continue;
|
|
208
|
-
|
|
209
|
-
// ls -la columns: perms links owner group size month day time name
|
|
210
|
-
const parts = line.split(/\s+/);
|
|
211
|
-
if (parts.length < 9) continue;
|
|
212
|
-
|
|
213
|
-
const perms = parts[0];
|
|
214
|
-
const size = parseInt(parts[4], 10) || 0;
|
|
215
|
-
const month = parts[5];
|
|
216
|
-
const day = parts[6];
|
|
217
|
-
const time = parts[7];
|
|
218
|
-
// Name may contain spaces — rejoin everything from column 8 onward
|
|
219
|
-
const name = parts.slice(8).join(' ');
|
|
220
|
-
|
|
221
|
-
// Skip . and .. entries
|
|
222
|
-
if (name === '.' || name === '..') continue;
|
|
127
|
+
const result = await this.#client.fileList(remotePath, timeout);
|
|
223
128
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (perms.startsWith('d')) type = 'directory';
|
|
227
|
-
else if (perms.startsWith('l')) type = 'symlink';
|
|
228
|
-
else if (perms.startsWith('c') || perms.startsWith('b')) type = 'device';
|
|
229
|
-
else if (perms.startsWith('p')) type = 'pipe';
|
|
230
|
-
else if (perms.startsWith('s')) type = 'socket';
|
|
231
|
-
|
|
232
|
-
entries.push({
|
|
233
|
-
name,
|
|
234
|
-
size,
|
|
235
|
-
modified: `${month} ${day} ${time}`,
|
|
236
|
-
type,
|
|
237
|
-
});
|
|
129
|
+
if (!result.success) {
|
|
130
|
+
throw new Error(result.error_message || `List failed: refused for ${remotePath}`);
|
|
238
131
|
}
|
|
239
132
|
|
|
240
|
-
return entries
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
*/
|
|
248
|
-
_shellEscape(str) {
|
|
249
|
-
// Wrap in single quotes, escaping any embedded single quotes
|
|
250
|
-
return "'" + str.replace(/'/g, "'\\''") + "'";
|
|
133
|
+
return (result.entries || []).map((entry) => ({
|
|
134
|
+
name: entry.name,
|
|
135
|
+
size: entry.size,
|
|
136
|
+
modified: new Date(entry.modified * 1000),
|
|
137
|
+
type: entry.type,
|
|
138
|
+
...(entry.symlink_target !== undefined ? { symlinkTarget: entry.symlink_target } : {}),
|
|
139
|
+
}));
|
|
251
140
|
}
|
|
252
141
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -778,6 +778,14 @@ export function fileResult(opts?: {
|
|
|
778
778
|
channelId?: number;
|
|
779
779
|
success?: boolean;
|
|
780
780
|
metadata?: Record<string, unknown>;
|
|
781
|
+
/** wsh #59: typed directory entries for a "list" op (see WshFileListEntry-shaped wire fields). */
|
|
782
|
+
entries?: Array<{
|
|
783
|
+
name: string;
|
|
784
|
+
size: number;
|
|
785
|
+
modified: number;
|
|
786
|
+
type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
|
|
787
|
+
symlink_target?: string;
|
|
788
|
+
}>;
|
|
781
789
|
errorMessage?: string;
|
|
782
790
|
}): WshMessage;
|
|
783
791
|
|
|
@@ -789,6 +797,18 @@ export function fileChunk(opts?: {
|
|
|
789
797
|
totalSize?: number;
|
|
790
798
|
}): WshMessage;
|
|
791
799
|
|
|
800
|
+
/** wsh #59: install a public key into the remote's authorized_keys (see WshClient.addAuthorizedKey). */
|
|
801
|
+
export function authorizedKeyAdd(opts?: {
|
|
802
|
+
publicKey?: Uint8Array;
|
|
803
|
+
comment?: string;
|
|
804
|
+
}): WshMessage;
|
|
805
|
+
|
|
806
|
+
export function authorizedKeyResult(opts?: {
|
|
807
|
+
success?: boolean;
|
|
808
|
+
added?: boolean;
|
|
809
|
+
errorMessage?: string;
|
|
810
|
+
}): WshMessage;
|
|
811
|
+
|
|
792
812
|
export function policyEval(opts?: {
|
|
793
813
|
requestId?: string;
|
|
794
814
|
action?: string;
|
|
@@ -1649,6 +1669,28 @@ export interface WshE2EResult {
|
|
|
1649
1669
|
hybrid: boolean;
|
|
1650
1670
|
}
|
|
1651
1671
|
|
|
1672
|
+
/**
|
|
1673
|
+
* Raw `FileResult` response to a structured file-channel operation
|
|
1674
|
+
* (`WshClient.fileList`/`fileRemove`/etc.), as received off the wire --
|
|
1675
|
+
* wire field names (snake_case), not the camelCase shape the `fileResult()`
|
|
1676
|
+
* message constructor takes as input. `entries` is only populated for a
|
|
1677
|
+
* `"list"` op; `error_message` is only present on failure.
|
|
1678
|
+
*/
|
|
1679
|
+
export interface WshFileOperationResult {
|
|
1680
|
+
type: number;
|
|
1681
|
+
channel_id: number;
|
|
1682
|
+
success: boolean;
|
|
1683
|
+
metadata: Record<string, unknown>;
|
|
1684
|
+
entries: Array<{
|
|
1685
|
+
name: string;
|
|
1686
|
+
size: number;
|
|
1687
|
+
modified: number;
|
|
1688
|
+
type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
|
|
1689
|
+
symlink_target?: string;
|
|
1690
|
+
}>;
|
|
1691
|
+
error_message?: string;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1652
1694
|
/**
|
|
1653
1695
|
* WshClient -- manages a wsh connection, authentication, and multiple sessions.
|
|
1654
1696
|
*
|
|
@@ -1764,6 +1806,36 @@ export class WshClient {
|
|
|
1764
1806
|
*/
|
|
1765
1807
|
revokeSessionAccess(sessionId: string, principal: string, reason?: string): Promise<void>;
|
|
1766
1808
|
|
|
1809
|
+
/**
|
|
1810
|
+
* List a remote directory over the structured file channel (`FileOp`
|
|
1811
|
+
* op `"list"`). Prefer `WshFileTransfer.list()` for a typed
|
|
1812
|
+
* `WshFileListEntry[]` result (with `modified` as a `Date`) -- this is
|
|
1813
|
+
* the raw wire-shaped `FileResult` response `fileOperation()` waits on.
|
|
1814
|
+
*/
|
|
1815
|
+
fileList(path: string, timeout?: number): Promise<WshFileOperationResult>;
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* Remove a remote file or directory (`FileOp` op `"remove"`). Refused
|
|
1819
|
+
* today by every `wsh-server` release ("not yet implemented" -- see
|
|
1820
|
+
* README's file-op parity table); wired here so clients built against
|
|
1821
|
+
* this SDK don't need to wait on the server to type-check against it.
|
|
1822
|
+
*/
|
|
1823
|
+
fileRemove(path: string, timeout?: number): Promise<WshFileOperationResult>;
|
|
1824
|
+
|
|
1825
|
+
/**
|
|
1826
|
+
* Install a raw 32-byte Ed25519 public key into the remote host's
|
|
1827
|
+
* `~/.wsh/authorized_keys` (wsh #59's `AuthorizedKeyAdd` protocol
|
|
1828
|
+
* message -- replaces `wsh copy-id`'s previous CLI-only shell-command
|
|
1829
|
+
* approach with something every implementation, including this one,
|
|
1830
|
+
* can call directly). Idempotent: installing an already-present key
|
|
1831
|
+
* resolves `added: false` rather than duplicating the line or erroring.
|
|
1832
|
+
*/
|
|
1833
|
+
addAuthorizedKey(
|
|
1834
|
+
publicKeyRaw: Uint8Array,
|
|
1835
|
+
comment?: string,
|
|
1836
|
+
timeout?: number,
|
|
1837
|
+
): Promise<{ added: boolean }>;
|
|
1838
|
+
|
|
1767
1839
|
/**
|
|
1768
1840
|
* Gracefully disconnect: close all sessions and the transport.
|
|
1769
1841
|
*/
|
|
@@ -1993,6 +2065,48 @@ export class WshKeyStore {
|
|
|
1993
2065
|
restore(passphrase: string): Promise<WshRestoreResult>;
|
|
1994
2066
|
}
|
|
1995
2067
|
|
|
2068
|
+
// ============================================================================
|
|
2069
|
+
// known-hosts.mjs -- WshKnownHosts (wsh #59)
|
|
2070
|
+
// ============================================================================
|
|
2071
|
+
|
|
2072
|
+
/** Minimal storage interface `WshKnownHosts` needs -- `localStorage` already satisfies it. */
|
|
2073
|
+
export interface WshKnownHostsStorage {
|
|
2074
|
+
getItem(key: string): string | null;
|
|
2075
|
+
setItem(key: string, value: string): void;
|
|
2076
|
+
removeItem(key: string): void;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
/** Result of verifying a host's fingerprint against the stored record. */
|
|
2080
|
+
export interface WshHostVerifyResult {
|
|
2081
|
+
status: 'known' | 'unknown' | 'changed';
|
|
2082
|
+
/** Previously stored fingerprint, present only when status === 'changed'. */
|
|
2083
|
+
expected?: string;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
/**
|
|
2087
|
+
* WshKnownHosts -- trust-on-first-use (TOFU) host-identity store, mirroring
|
|
2088
|
+
* Rust's `KnownHosts`/`HostStatus` (`crates/wsh-client/src/known_hosts.rs`).
|
|
2089
|
+
* Pins `ServerHello.host_fingerprint` (spec/wsh-v1.yaml, wsh #59) -- no
|
|
2090
|
+
* `wsh-server` release populates that field yet, so this has nothing
|
|
2091
|
+
* authoritative to verify against for any real server today. See the
|
|
2092
|
+
* class doc comment in `known-hosts.mjs` for the full reasoning.
|
|
2093
|
+
*/
|
|
2094
|
+
export class WshKnownHosts {
|
|
2095
|
+
constructor(opts?: { storage?: WshKnownHostsStorage; storageKey?: string });
|
|
2096
|
+
|
|
2097
|
+
/** Verify a host's fingerprint against the stored record. */
|
|
2098
|
+
verifyHost(host: string, fingerprint: string): WshHostVerifyResult;
|
|
2099
|
+
|
|
2100
|
+
/** Add or update a host's fingerprint. */
|
|
2101
|
+
addHost(host: string, fingerprint: string): void;
|
|
2102
|
+
|
|
2103
|
+
/** Remove a host entry. Returns true if an entry was removed. */
|
|
2104
|
+
removeHost(host: string): boolean;
|
|
2105
|
+
|
|
2106
|
+
/** List all known hosts and their fingerprints. */
|
|
2107
|
+
list(): Array<{ host: string; fingerprint: string }>;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
1996
2110
|
// ============================================================================
|
|
1997
2111
|
// file-transfer.mjs -- WshFileTransfer
|
|
1998
2112
|
// ============================================================================
|
|
@@ -2015,12 +2129,21 @@ export interface WshFileUploadResult {
|
|
|
2015
2129
|
bytesTransferred: number;
|
|
2016
2130
|
}
|
|
2017
2131
|
|
|
2018
|
-
/**
|
|
2132
|
+
/**
|
|
2133
|
+
* File listing entry (wsh #59: generated from spec/wsh-v1.yaml's
|
|
2134
|
+
* `FileEntry` nested type -- the same shape `crates/wsh-core`'s Rust
|
|
2135
|
+
* `FileEntry` struct is generated from, so a directory entry can no longer
|
|
2136
|
+
* describe a symlink as a plain file in one implementation and not the
|
|
2137
|
+
* other). `modified` is converted from the wire's Unix epoch seconds into
|
|
2138
|
+
* a `Date` for JS ergonomics. `symlinkTarget` is only present when
|
|
2139
|
+
* `type === 'symlink'`.
|
|
2140
|
+
*/
|
|
2019
2141
|
export interface WshFileListEntry {
|
|
2020
2142
|
name: string;
|
|
2021
2143
|
size: number;
|
|
2022
|
-
modified:
|
|
2144
|
+
modified: Date;
|
|
2023
2145
|
type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
|
|
2146
|
+
symlinkTarget?: string;
|
|
2024
2147
|
}
|
|
2025
2148
|
|
|
2026
2149
|
/**
|
package/src/index.mjs
CHANGED
|
@@ -35,6 +35,7 @@ export {
|
|
|
35
35
|
nodeAnnounce, nodeRedirect,
|
|
36
36
|
sessionGrant, sessionRevoke,
|
|
37
37
|
fileOp, fileResult, fileChunk,
|
|
38
|
+
authorizedKeyAdd, authorizedKeyResult,
|
|
38
39
|
policyEval, policyResult, policyUpdate,
|
|
39
40
|
terminalConfig,
|
|
40
41
|
msgName, isValidMessage,
|
|
@@ -77,6 +78,9 @@ export { WshClient, MCP_CALL_ID_FEATURE } from './client.mjs';
|
|
|
77
78
|
// Key storage
|
|
78
79
|
export { WshKeyStore } from './keystore.mjs';
|
|
79
80
|
|
|
81
|
+
// Host identity (TOFU) -- wsh #59
|
|
82
|
+
export { WshKnownHosts } from './known-hosts.mjs';
|
|
83
|
+
|
|
80
84
|
// File transfer
|
|
81
85
|
export { WshFileTransfer } from './file-transfer.mjs';
|
|
82
86
|
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WshKnownHosts — trust-on-first-use (TOFU) host-identity store for the
|
|
3
|
+
* browser SDK (wsh #59).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists, and why it is not yet load-bearing
|
|
6
|
+
*
|
|
7
|
+
* wsh #59 asked for a decision on host identity: TOFU *policy* (when to
|
|
8
|
+
* prompt, how long to trust) is legitimately per-implementation, but the
|
|
9
|
+
* *host identity record* -- what a fingerprint covers, how it is computed,
|
|
10
|
+
* what counts as "the same host" -- must not be invented twice. This class
|
|
11
|
+
* mirrors Rust's `KnownHosts`/`HostStatus`
|
|
12
|
+
* (`crates/wsh-client/src/known_hosts.rs`) in shape and semantics --
|
|
13
|
+
* `verifyHost`/`addHost`/`removeHost`/`list`, keyed by a host label,
|
|
14
|
+
* storing one fingerprint per host -- so the browser gets the same
|
|
15
|
+
* mechanism rather than a second, differently-shaped answer arrived at
|
|
16
|
+
* later under pressure.
|
|
17
|
+
*
|
|
18
|
+
* What it pins is `ServerHello.host_fingerprint` (spec/wsh-v1.yaml, added
|
|
19
|
+
* by this same change): the server's own persistent Ed25519 host-identity
|
|
20
|
+
* key, SHA-256-fingerprinted the same way `fingerprint()` in `auth.mjs`
|
|
21
|
+
* already fingerprints user/peer keys. That field is new spec surface,
|
|
22
|
+
* not new server behavior -- no `wsh-server` release populates it yet.
|
|
23
|
+
* Minting and persisting a server host keypair is a separate,
|
|
24
|
+
* security-sensitive feature this change does not implement (see the
|
|
25
|
+
* wsh #59 PR description for the full reasoning). Until a server
|
|
26
|
+
* populates it, `verifyHost()` has nothing authoritative to check for any
|
|
27
|
+
* real deployment -- exactly the same degraded state Rust's own
|
|
28
|
+
* `verify_host_key` is in today when it falls back to `fingerprints[0]`
|
|
29
|
+
* (an authorized *client* key, not a host identity -- see that fallback's
|
|
30
|
+
* comment in `client.rs`). This store does not paper over that; a caller
|
|
31
|
+
* that gets `'unknown'` back from every real server today is seeing the
|
|
32
|
+
* honest state of host verification in this protocol version, not a bug
|
|
33
|
+
* in this class.
|
|
34
|
+
*
|
|
35
|
+
* The README's `serverCertificateHashes` pinning
|
|
36
|
+
* (see "Pinning a Self-Signed Certificate") is a different mechanism: it
|
|
37
|
+
* pins a certificate supplied *per connection*, decided by the caller in
|
|
38
|
+
* advance -- it is not a persisted record of what a host presented last
|
|
39
|
+
* time, so it does not already solve TOFU. `WshKnownHosts` is the
|
|
40
|
+
* missing persisted-record half.
|
|
41
|
+
*
|
|
42
|
+
* ## Storage
|
|
43
|
+
*
|
|
44
|
+
* Pluggable (defaults to `localStorage` when available, else an
|
|
45
|
+
* in-memory `Map` that does not survive a page reload) since "persist
|
|
46
|
+
* trust across sessions" needs a browser storage API this package has
|
|
47
|
+
* never assumed before. Pass `{ storage }` with a `getItem`/`setItem`/
|
|
48
|
+
* `removeItem` interface (the `Storage` interface `localStorage`
|
|
49
|
+
* already implements) to use IndexedDB-via-adapter, `sessionStorage`,
|
|
50
|
+
* or anything else.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const STORAGE_KEY = 'wsh:known-hosts:v1';
|
|
54
|
+
|
|
55
|
+
/** In-memory fallback storage, used when no persistent storage is available or injected. */
|
|
56
|
+
class MemoryStorage {
|
|
57
|
+
#map = new Map();
|
|
58
|
+
getItem(key) { return this.#map.has(key) ? this.#map.get(key) : null; }
|
|
59
|
+
setItem(key, value) { this.#map.set(key, value); }
|
|
60
|
+
removeItem(key) { this.#map.delete(key); }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function defaultStorage() {
|
|
64
|
+
if (typeof localStorage !== 'undefined' && localStorage) return localStorage;
|
|
65
|
+
return new MemoryStorage();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class WshKnownHosts {
|
|
69
|
+
#storage;
|
|
70
|
+
#storageKey;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {object} [opts]
|
|
74
|
+
* @param {{getItem: function, setItem: function, removeItem: function}} [opts.storage]
|
|
75
|
+
* Storage backend (defaults to `localStorage` if present, else an
|
|
76
|
+
* in-memory `Map` scoped to this instance).
|
|
77
|
+
* @param {string} [opts.storageKey] - Override the storage key (useful
|
|
78
|
+
* for isolating multiple instances/tests under one storage backend).
|
|
79
|
+
*/
|
|
80
|
+
constructor({ storage, storageKey = STORAGE_KEY } = {}) {
|
|
81
|
+
this.#storage = storage || defaultStorage();
|
|
82
|
+
this.#storageKey = storageKey;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Verify a host's fingerprint against the stored record.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} host - Host label (e.g. "example.com:4422")
|
|
89
|
+
* @param {string} fingerprint - Hex SHA-256 fingerprint to check
|
|
90
|
+
* @returns {{ status: 'known' | 'unknown' | 'changed', expected?: string }}
|
|
91
|
+
*/
|
|
92
|
+
verifyHost(host, fingerprint) {
|
|
93
|
+
const entries = this.#load();
|
|
94
|
+
if (!(host in entries)) {
|
|
95
|
+
return { status: 'unknown' };
|
|
96
|
+
}
|
|
97
|
+
if (entries[host] === fingerprint) {
|
|
98
|
+
return { status: 'known' };
|
|
99
|
+
}
|
|
100
|
+
return { status: 'changed', expected: entries[host] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Add or update a host's fingerprint. */
|
|
104
|
+
addHost(host, fingerprint) {
|
|
105
|
+
const entries = this.#load();
|
|
106
|
+
entries[host] = fingerprint;
|
|
107
|
+
this.#save(entries);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Remove a host entry. Returns true if an entry was removed. */
|
|
111
|
+
removeHost(host) {
|
|
112
|
+
const entries = this.#load();
|
|
113
|
+
if (!(host in entries)) return false;
|
|
114
|
+
delete entries[host];
|
|
115
|
+
this.#save(entries);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** List all known hosts and their fingerprints. */
|
|
120
|
+
list() {
|
|
121
|
+
const entries = this.#load();
|
|
122
|
+
return Object.entries(entries).map(([host, fingerprint]) => ({ host, fingerprint }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#load() {
|
|
126
|
+
const raw = this.#storage.getItem(this.#storageKey);
|
|
127
|
+
if (!raw) return {};
|
|
128
|
+
try {
|
|
129
|
+
const parsed = JSON.parse(raw);
|
|
130
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
131
|
+
} catch {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#save(entries) {
|
|
137
|
+
this.#storage.setItem(this.#storageKey, JSON.stringify(entries));
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/messages.gen.mjs
CHANGED
|
@@ -145,6 +145,10 @@ export const MSG = Object.freeze({
|
|
|
145
145
|
|
|
146
146
|
// Terminal
|
|
147
147
|
TERMINAL_CONFIG: 0x9e,
|
|
148
|
+
|
|
149
|
+
// Keys
|
|
150
|
+
AUTHORIZED_KEY_ADD: 0x9f,
|
|
151
|
+
AUTHORIZED_KEY_RESULT: 0xa0,
|
|
148
152
|
});
|
|
149
153
|
|
|
150
154
|
// Reverse lookup: number → name
|
|
@@ -187,13 +191,10 @@ export function hello({ username, features = [], authMethod = AUTH_METHOD.PUBKEY
|
|
|
187
191
|
};
|
|
188
192
|
}
|
|
189
193
|
|
|
190
|
-
export function serverHello({ sessionId, features = [], fingerprints = [] } = {}) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
features,
|
|
195
|
-
fingerprints,
|
|
196
|
-
};
|
|
194
|
+
export function serverHello({ sessionId, features = [], fingerprints = [], hostFingerprint } = {}) {
|
|
195
|
+
const msg = { type: MSG.SERVER_HELLO, session_id: sessionId, features, fingerprints };
|
|
196
|
+
if (hostFingerprint !== undefined) msg.host_fingerprint = hostFingerprint;
|
|
197
|
+
return msg;
|
|
197
198
|
}
|
|
198
199
|
|
|
199
200
|
export function challenge({ nonce, sessionId } = {}) {
|
|
@@ -864,8 +865,8 @@ export function fileOp({ channelId, op, path, offset, length } = {}) {
|
|
|
864
865
|
return msg;
|
|
865
866
|
}
|
|
866
867
|
|
|
867
|
-
export function fileResult({ channelId, success, metadata = {}, errorMessage } = {}) {
|
|
868
|
-
const msg = { type: MSG.FILE_RESULT, channel_id: channelId, success, metadata };
|
|
868
|
+
export function fileResult({ channelId, success, metadata = {}, entries = [], errorMessage } = {}) {
|
|
869
|
+
const msg = { type: MSG.FILE_RESULT, channel_id: channelId, success, metadata, entries };
|
|
869
870
|
if (errorMessage !== undefined) msg.error_message = errorMessage;
|
|
870
871
|
return msg;
|
|
871
872
|
}
|
|
@@ -915,6 +916,18 @@ export function terminalConfig({ channelId, frontend, options = {} } = {}) {
|
|
|
915
916
|
};
|
|
916
917
|
}
|
|
917
918
|
|
|
919
|
+
export function authorizedKeyAdd({ publicKey, comment } = {}) {
|
|
920
|
+
const msg = { type: MSG.AUTHORIZED_KEY_ADD, public_key: publicKey };
|
|
921
|
+
if (comment !== undefined) msg.comment = comment;
|
|
922
|
+
return msg;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
export function authorizedKeyResult({ success, added = false, errorMessage } = {}) {
|
|
926
|
+
const msg = { type: MSG.AUTHORIZED_KEY_RESULT, success, added };
|
|
927
|
+
if (errorMessage !== undefined) msg.error_message = errorMessage;
|
|
928
|
+
return msg;
|
|
929
|
+
}
|
|
930
|
+
|
|
918
931
|
// ── Relay forwarding ─────────────────────────────────────────────────
|
|
919
932
|
|
|
920
933
|
// Message types a relay may forward inside a RelayForward wrapper.
|