@johnhenry/wsh 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -167,8 +167,9 @@ const server = createWshServer({
167
167
  host: '127.0.0.1',
168
168
  port: 4422, // 0 = pick a free port
169
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)
170
171
  exec: true, // opt in: run commands via /bin/sh
171
- fs: { root: '/srv/share', readOnly: false }, // opt in: list/stat/read/mkdir/remove/upload/download
172
+ fs: { root: '/srv/share', readOnly: false }, // opt in: list/stat/read/write/rename/mkdir/remove/upload/download
172
173
  // pty: { spawn: nodePty.spawn }, // opt in: bring your own node-pty
173
174
  });
174
175
  const { port } = await server.listen(); // later: server.address(), await server.close()
@@ -176,17 +177,121 @@ const { port } = await server.listen(); // later: server.address(
176
177
 
177
178
  Everything that touches the machine -- `exec`, `pty`, `fs` -- is **off unless
178
179
  you pass it**, and with no `auth` every connection is refused. `auth` is
179
- `{ authorizedKeys, authorize }` (a key must be on the list and pass
180
- `authorize({ username, fingerprint, publicKey })` when both are given) or
181
- just an `authorize` function. `exec` can also be `{ cwd, env, shell,
182
- timeoutMs, clientEnv }` or a custom `run(command, io)` for a restricted host
183
- with no shell. `fs.root` confines every path: `..`, absolute paths and
184
- symlinks pointing out of it are refused. The server advertises
185
- `stream-announce`, so a current client writes no primer byte; a lone leading
186
- `0x00` from a client that primes anyway is dropped rather than reaching stdin.
187
-
188
- Not implemented: WebTransport, relay/reverse mode, attach/resume, MCP,
189
- password auth.
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. |
190
295
 
191
296
  ## Attach and Resume
192
297
 
@@ -349,7 +454,7 @@ than left to be found.
349
454
  | Upload/download | `WshClient.upload()`/`.download()` | `file_transfer::upload()`/`download()`, `wsh scp` | **Wire-unified**: `FileChunk` over a `'file'`-kind channel (wsh #13) |
350
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 |
351
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 |
352
- | 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" |
353
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 |
354
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 |
355
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 |
@@ -443,8 +548,11 @@ out explicitly below rather than left to be discovered.
443
548
  - **Host identity (TOFU) has a known, named gap (wsh #59).**
444
549
  `ServerHello.host_fingerprint` is the spec's formal host-identity slot:
445
550
  the SHA-256 fingerprint of a server's persistent Ed25519 host key, meant
446
- to be pinned across connections the way SSH pins a host key. **No
447
- `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
448
556
  persisting a server host keypair is a separate, security-sensitive
449
557
  feature, deliberately not bundled into this change. Both clients ship
450
558
  the store this field is for: `WshKnownHosts` (JS, `localStorage`-backed)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
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",
package/src/client.mjs CHANGED
@@ -40,6 +40,11 @@ import { signChallenge, exportPublicKeyRaw, signPeerRecord, verifyPeerRecord, im
40
40
  import { generateMlKemKeyPair, mlKemEncapsulate, mlKemDecapsulate } from './mlkem.mjs';
41
41
  import { WshSession } from './session.mjs';
42
42
  import { cborDecode } from './cbor.mjs';
43
+ import {
44
+ HostKeyError, newHostKeyNonce, hostKeyProofMessage, findHostKeyAdvert,
45
+ HOST_KEY_NONCE_PREFIX,
46
+ } from './host-key.mjs';
47
+ import { verify as verifySignature, exportPublicKeySSH, parseSSHPublicKey, extractRawFromSSHWire } from './auth.mjs';
43
48
 
44
49
  // ── Client states ─────────────────────────────────────────────────────
45
50
 
@@ -175,6 +180,28 @@ async function combineHybridSecret(x25519Bits, kemSharedSecret) {
175
180
  return new Uint8Array(bits);
176
181
  }
177
182
 
183
+ /**
184
+ * `expectHostKey` accepts a hex fingerprint, a raw 32-byte key, or an
185
+ * `ssh-ed25519 AAAA...` line; all normalize to the hex fingerprint.
186
+ * @param {string|Uint8Array} v
187
+ * @returns {Promise<string>}
188
+ */
189
+ async function normalizeExpectedHostKey(v) {
190
+ if (v instanceof Uint8Array) {
191
+ if (v.byteLength !== 32) throw new TypeError('expectHostKey: a raw key must be 32 bytes');
192
+ return computeFingerprint(v);
193
+ }
194
+ const text = String(v).trim();
195
+ if (/^ssh-ed25519\s/.test(text)) {
196
+ const parsed = parseSSHPublicKey(text);
197
+ if (!parsed) throw new TypeError('expectHostKey: malformed ssh-ed25519 line');
198
+ return computeFingerprint(extractRawFromSSHWire(parsed.data));
199
+ }
200
+ const hex = text.replace(/^sha256:/i, '').replace(/:/g, '').toLowerCase();
201
+ if (!/^[0-9a-f]{64}$/.test(hex)) throw new TypeError('expectHostKey: expected a 64-char hex fingerprint, a raw key, or an ssh-ed25519 line');
202
+ return hex;
203
+ }
204
+
178
205
  // ── Client class ──────────────────────────────────────────────────────
179
206
 
180
207
  export class WshClient {
@@ -201,6 +228,16 @@ export class WshClient {
201
228
  /** @type {import('./transport.mjs').WshTransport|null} Active transport. */
202
229
  #transport = null;
203
230
 
231
+ /** @type {{fingerprint: string, publicKey: Uint8Array, openssh: string, status: string}|null} */
232
+ #hostKey = null;
233
+
234
+ /**
235
+ * Called with the host's verified key before any credential is sent.
236
+ * Return `false` (or throw) to refuse the host. See `connect({ onHostKey })`.
237
+ * @type {((hostKey: object) => boolean|void|Promise<boolean|void>)|null}
238
+ */
239
+ onHostKey = null;
240
+
204
241
  /** @type {{ wt: () => import('./transport.mjs').WshTransport, ws: () => import('./transport.mjs').WshTransport }} */
205
242
  #transportFactories;
206
243
 
@@ -409,6 +446,18 @@ export class WshClient {
409
446
  return this.#serverFeatures.includes(name);
410
447
  }
411
448
 
449
+ /**
450
+ * The host's Ed25519 identity, once the server has proven it holds the
451
+ * key for this connection (`null` when the server advertised none, or
452
+ * before `connect()` got that far). `{ fingerprint, publicKey, openssh,
453
+ * status }` where `status` is `'pinned'` (matched `expectHostKey`),
454
+ * `'known'` / `'unknown'` (per `knownHosts`), or `'unpinned'` (no policy
455
+ * asked for one -- the key is surfaced but nothing was checked against it).
456
+ */
457
+ get hostKey() {
458
+ return this.#hostKey;
459
+ }
460
+
412
461
  // ── Connection ──────────────────────────────────────────────────────
413
462
 
414
463
  /**
@@ -429,9 +478,21 @@ export class WshClient {
429
478
  * fine), or base64. Ignored by the WebSocket rung, which has no
430
479
  * equivalent mechanism.
431
480
  * @param {number} [opts.timeout] - Auth handshake timeout in ms
481
+ * @param {string|Uint8Array} [opts.expectHostKey] - Pin: the host's hex
482
+ * SHA-256 fingerprint, raw 32-byte Ed25519 key, or `ssh-ed25519 AAAA...`
483
+ * line. Refuses (`HostKeyError`) on mismatch OR when the host presents
484
+ * no verifiable key -- before any credential is sent.
485
+ * @param {import('./known-hosts.mjs').WshKnownHosts} [opts.knownHosts] - TOFU
486
+ * store. A changed key is always refused; an unseen host is refused
487
+ * unless `trustOnFirstUse` (or an `onHostKey` that does not return
488
+ * `false`) accepts it, in which case it is pinned.
489
+ * @param {boolean} [opts.trustOnFirstUse] - Pin an unseen host's key.
490
+ * @param {string} [opts.hostLabel] - Key under which `knownHosts` records
491
+ * this host (default: the URL's `host:port`).
492
+ * @param {Function} [opts.onHostKey] - Per-connect `onHostKey`.
432
493
  * @returns {Promise<string>} The server-assigned session ID
433
494
  */
434
- async connect(url, { username, keyPair, password, transport: transportHint, webTransport, timeout = DEFAULT_AUTH_TIMEOUT } = {}) {
495
+ async connect(url, { username, keyPair, password, transport: transportHint, webTransport, timeout = DEFAULT_AUTH_TIMEOUT, ...hostKeyOpts } = {}) {
435
496
  if (this.#state !== STATE_DISCONNECTED && this.#state !== STATE_CLOSED) {
436
497
  throw new Error(`Client already ${this.#state}`);
437
498
  }
@@ -448,6 +509,7 @@ export class WshClient {
448
509
  this.#waiters.clear();
449
510
  this.#sessionId = null;
450
511
  this.#authToken = null;
512
+ this.#hostKey = null;
451
513
 
452
514
  try {
453
515
  // ── Select and connect transport ──────────────────────────────
@@ -457,9 +519,11 @@ export class WshClient {
457
519
 
458
520
  // ── Auth handshake ────────────────────────────────────────────
459
521
  const authMethod = keyPair ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
522
+ const hostKeyNonce = newHostKeyNonce();
460
523
  await transport.sendControl(
461
- hello({ username, authMethod })
524
+ hello({ username, authMethod, features: [HOST_KEY_NONCE_PREFIX + hostKeyNonce] })
462
525
  );
526
+ const hostCtx = { ...hostKeyOpts, url, username, nonce: hostKeyNonce };
463
527
 
464
528
  // Wait for SERVER_HELLO (which may include a session ID directly) or CHALLENGE.
465
529
  const firstResponse = await this.#waitForMessage(
@@ -478,14 +542,18 @@ export class WshClient {
478
542
  // Server may proceed directly to auth if it accepted the hello.
479
543
  tempSessionId = firstResponse.session_id;
480
544
  this.#serverFeatures = firstResponse.features || [];
545
+ // If pubkey auth, we still need a challenge. Its waiter is registered
546
+ // BEFORE the host key check awaits anything: the server sends the
547
+ // Challenge right behind ServerHello, and a message nobody is waiting
548
+ // for yet is dropped.
549
+ const challengeWait = authMethod === AUTH_METHOD.PUBKEY
550
+ ? this.#waitForMessage([MSG.CHALLENGE, MSG.AUTH_OK], timeout, 'Auth handshake timed out waiting for challenge')
551
+ : null;
552
+ challengeWait?.catch(() => {});
553
+ await this.#verifyHostKey(firstResponse, hostCtx);
481
554
 
482
- // If pubkey auth, we still need a challenge.
483
555
  if (authMethod === AUTH_METHOD.PUBKEY) {
484
- const challengeMsg = await this.#waitForMessage(
485
- [MSG.CHALLENGE, MSG.AUTH_OK],
486
- timeout,
487
- 'Auth handshake timed out waiting for challenge'
488
- );
556
+ const challengeMsg = await challengeWait;
489
557
 
490
558
  if (challengeMsg.type === MSG.AUTH_OK) {
491
559
  // Server accepted without challenge (e.g. trusted key).
@@ -525,6 +593,7 @@ export class WshClient {
525
593
  }
526
594
  } else if (firstResponse.type === MSG.CHALLENGE) {
527
595
  // Some servers skip SERVER_HELLO and go straight to CHALLENGE.
596
+ await this.#verifyHostKey(null, hostCtx);
528
597
  if (authMethod !== AUTH_METHOD.PUBKEY || !keyPair) {
529
598
  throw new Error('Server sent CHALLENGE but no key pair was provided');
530
599
  }
@@ -858,13 +927,13 @@ export class WshClient {
858
927
  * @param {boolean} [opts.primer=true] - See `openSession({ primer })`
859
928
  * @returns {Promise<{stdout: Uint8Array, exitCode: number}>}
860
929
  */
861
- static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT, primer = true } = {}) {
930
+ static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT, primer = true, ...hostKeyOpts } = {}) {
862
931
  const client = new WshClient();
863
932
  const chunks = [];
864
933
  let exitCode = -1;
865
934
 
866
935
  try {
867
- await client.connect(url, { username, keyPair, password });
936
+ await client.connect(url, { username, keyPair, password, ...hostKeyOpts });
868
937
 
869
938
  const session = await client.openSession({ type: 'exec', command, primer });
870
939
 
@@ -935,9 +1004,10 @@ export class WshClient {
935
1004
  supportsReplay,
936
1005
  supportsEcho,
937
1006
  supportsTermSync,
1007
+ ...hostKeyOpts
938
1008
  } = {}) {
939
1009
  // Authenticate normally first.
940
- const sessionId = await this.connect(url, { username, keyPair, password, webTransport });
1010
+ const sessionId = await this.connect(url, { username, keyPair, password, webTransport, ...hostKeyOpts });
941
1011
 
942
1012
  // Build capabilities list from expose options.
943
1013
  const capabilities = [];
@@ -1719,22 +1789,68 @@ export class WshClient {
1719
1789
 
1720
1790
  /**
1721
1791
  * Perform a file operation on the remote host.
1792
+ *
1793
+ * `write` and `rename` carry data the wsh-v1 `FileOp` frame has no field
1794
+ * for, so both send the payload as a `FileChunk` on the same channel id
1795
+ * (no frame or field outside the spec), and the host must advertise
1796
+ * `file-write` / `file-rename` in ServerHello; against a host that does
1797
+ * not, they throw instead of sending a request the host would misread:
1798
+ * - `write`: `FileOp{ op: 'write', path, offset?, length }`, then the bytes
1799
+ * as `FileChunk` frames (the last with `is_final`).
1800
+ * - `rename`: `FileOp{ op: 'rename', path: <old> }`, then one final
1801
+ * `FileChunk` whose data is the UTF-8 destination path.
1802
+ * Either way the single `FileResult` arrives after the final chunk.
1803
+ *
1722
1804
  * @param {string} op - Operation: 'stat', 'list', 'read', 'write', 'mkdir', 'remove', 'rename'
1723
1805
  * @param {string} path - File path
1724
- * @param {object} [opts] - Optional: offset, length for read; data for write; newPath for rename
1806
+ * @param {object} [opts] - Optional: offset, length for read; `data` (+ `offset`) for write; `newPath` for rename
1725
1807
  * @param {number} [timeout=10000]
1726
1808
  * @returns {Promise<object>} FileResult response
1727
1809
  */
1728
1810
  async fileOperation(op, path, opts = {}, timeout = DEFAULT_OPEN_TIMEOUT) {
1729
1811
  this.#assertAuthenticated('fileOperation');
1730
1812
  const channelId = this._nextChannelId();
1813
+ // FileOp and FileResult both carry channel_id; each call claims only the
1814
+ // result for its own id, so overlapping operations cannot swap replies (#72).
1815
+ const byChannel = (m) => m.channel_id === channelId;
1816
+
1817
+ if (op === 'write' || op === 'rename') {
1818
+ const feature = op === 'write' ? 'file-write' : 'file-rename';
1819
+ if (!this.hasFeature(feature)) throw new Error(`this host does not support file ${op} (no "${feature}" feature)`);
1820
+ let data;
1821
+ if (op === 'write') {
1822
+ data = typeof opts.data === 'string' ? new TextEncoder().encode(opts.data) : opts.data;
1823
+ if (!(data instanceof Uint8Array)) throw new TypeError('fileWrite: data must be a string or Uint8Array');
1824
+ } else {
1825
+ if (typeof opts.newPath !== 'string' || !opts.newPath) throw new TypeError('fileRename: newPath is required');
1826
+ data = new TextEncoder().encode(opts.newPath);
1827
+ }
1828
+ // Registered before sending: the host may fail fast (read-only, too big)
1829
+ // and answer before the last chunk goes out.
1830
+ const result = this.#waitForMessage([MSG.FILE_RESULT], timeout, `Timed out waiting for file ${op} result`, byChannel);
1831
+ result.catch(() => {});
1832
+ await this.#transport.sendControl(fileOpMsg({
1833
+ channelId, op, path, offset: op === 'write' ? opts.offset : undefined, length: data.byteLength,
1834
+ }));
1835
+ let off = 0;
1836
+ do {
1837
+ const end = Math.min(off + FILE_CHUNK_SIZE, data.byteLength);
1838
+ await this.#transport.sendControl(fileChunkMsg({
1839
+ channelId, offset: off, data: data.subarray(off, end), isFinal: end >= data.byteLength, totalSize: data.byteLength,
1840
+ }));
1841
+ off = end;
1842
+ } while (off < data.byteLength);
1843
+ return result;
1844
+ }
1845
+
1731
1846
  await this.#transport.sendControl(
1732
1847
  fileOpMsg({ channelId, op, path, offset: opts.offset, length: opts.length })
1733
1848
  );
1734
1849
  return this.#waitForMessage(
1735
1850
  [MSG.FILE_RESULT],
1736
1851
  timeout,
1737
- `Timed out waiting for file ${op} result`
1852
+ `Timed out waiting for file ${op} result`,
1853
+ byChannel
1738
1854
  );
1739
1855
  }
1740
1856
 
@@ -1745,13 +1861,13 @@ export class WshClient {
1745
1861
  /** Read a remote file. */
1746
1862
  async fileRead(path, offset, length, timeout) { return this.fileOperation('read', path, { offset, length }, timeout); }
1747
1863
  /** Write to a remote file. */
1748
- async fileWrite(path, data, offset, timeout) { return this.fileOperation('write', path, { offset }, timeout); }
1864
+ async fileWrite(path, data, offset, timeout) { return this.fileOperation('write', path, { data, offset }, timeout); }
1749
1865
  /** Create a remote directory. */
1750
1866
  async fileMkdir(path, timeout) { return this.fileOperation('mkdir', path, {}, timeout); }
1751
1867
  /** Remove a remote file or directory. */
1752
1868
  async fileRemove(path, timeout) { return this.fileOperation('remove', path, {}, timeout); }
1753
1869
  /** Rename a remote file or directory. */
1754
- async fileRename(oldPath, newPath, timeout) { return this.fileOperation('rename', oldPath, {}, timeout); }
1870
+ async fileRename(oldPath, newPath, timeout) { return this.fileOperation('rename', oldPath, { newPath }, timeout); }
1755
1871
 
1756
1872
  // ── Authorized-key management (wsh #59) ───────────────────────────
1757
1873
 
@@ -1955,7 +2071,95 @@ export class WshClient {
1955
2071
  // Proceed with auth using the same logic.
1956
2072
  // We can't call this.connect() directly because it would create a new
1957
2073
  // transport, so we duplicate the auth portion.
1958
- return this.#performAuth(opts);
2074
+ return this.#performAuth({ ...opts, url });
2075
+ }
2076
+
2077
+ // ── Internal: host key (TOFU / pinning) ─────────────────────────────
2078
+
2079
+ /**
2080
+ * Check the host's advertised key (see host-key.mjs) against the caller's
2081
+ * policy. Runs before any credential leaves this client. Throws a
2082
+ * `HostKeyError`; the surrounding handshake `catch` closes the transport.
2083
+ *
2084
+ * @param {object|null} serverHello - ServerHello, or null when the server went straight to CHALLENGE
2085
+ * @param {object} ctx - { url, username, nonce, expectHostKey, knownHosts, hostLabel, trustOnFirstUse, onHostKey }
2086
+ * @private
2087
+ */
2088
+ async #verifyHostKey(serverHello, ctx) {
2089
+ const { expectHostKey, knownHosts, trustOnFirstUse, nonce, username } = ctx;
2090
+ const wantsCheck = Boolean(expectHostKey || knownHosts);
2091
+ const advert = serverHello ? findHostKeyAdvert(serverHello.features) : null;
2092
+
2093
+ if (!advert) {
2094
+ if (wantsCheck) {
2095
+ throw new HostKeyError('HOST_KEY_MISSING',
2096
+ 'host key required (expectHostKey/knownHosts) but the server presented none; refusing before sending credentials');
2097
+ }
2098
+ return;
2099
+ }
2100
+
2101
+ const { key, sig } = advert;
2102
+ if (!key || key.byteLength !== 32 || !sig) {
2103
+ throw new HostKeyError('HOST_KEY_INVALID', 'server advertised a malformed host key or no proof of possession');
2104
+ }
2105
+ let ok = false;
2106
+ let fp;
2107
+ try {
2108
+ fp = await computeFingerprint(key);
2109
+ ok = await verifySignature(
2110
+ await importPublicKeyRaw(key), sig,
2111
+ hostKeyProofMessage({ sessionId: serverHello.session_id, clientNonce: nonce, username }),
2112
+ );
2113
+ } catch { ok = false; }
2114
+ if (!ok) throw new HostKeyError('HOST_KEY_INVALID', 'host key proof does not verify (not signed over this connection)');
2115
+ if (serverHello.host_fingerprint && serverHello.host_fingerprint !== fp) {
2116
+ throw new HostKeyError('HOST_KEY_INVALID', 'ServerHello.host_fingerprint does not match the advertised host key');
2117
+ }
2118
+
2119
+ let openssh = '';
2120
+ try { openssh = await exportPublicKeySSH(await importPublicKeyRaw(key)); } catch { /* informational only */ }
2121
+ const info = { fingerprint: fp, publicKey: key, openssh, status: 'unpinned' };
2122
+ this.#hostKey = info;
2123
+
2124
+ if (expectHostKey) {
2125
+ const want = await normalizeExpectedHostKey(expectHostKey);
2126
+ if (want !== fp) {
2127
+ throw new HostKeyError('HOST_KEY_MISMATCH',
2128
+ `host key mismatch: expected ${want}, host presented ${fp}`, { expected: want, actual: fp });
2129
+ }
2130
+ info.status = 'pinned';
2131
+ }
2132
+
2133
+ let label = ctx.hostLabel;
2134
+ if (knownHosts) {
2135
+ if (!label) {
2136
+ try { label = new URL(ctx.url).host; } catch { label = String(ctx.url); }
2137
+ }
2138
+ const v = knownHosts.verifyHost(label, fp);
2139
+ if (v.status === 'changed') {
2140
+ throw new HostKeyError('HOST_KEY_MISMATCH',
2141
+ `HOST KEY CHANGED for ${label}: pinned ${v.expected}, host presented ${fp} (possible impersonation)`,
2142
+ { expected: v.expected, actual: fp });
2143
+ }
2144
+ info.status = v.status; // 'known' | 'unknown'
2145
+ }
2146
+
2147
+ const cb = ctx.onHostKey ?? this.onHostKey;
2148
+ if (typeof cb === 'function') {
2149
+ let verdict;
2150
+ try { verdict = await cb({ ...info }); } catch (e) {
2151
+ throw new HostKeyError('HOST_KEY_REJECTED', `onHostKey threw: ${e?.message ?? e}`);
2152
+ }
2153
+ if (verdict === false) throw new HostKeyError('HOST_KEY_REJECTED', 'host key rejected by onHostKey');
2154
+ }
2155
+
2156
+ if (knownHosts && info.status === 'unknown') {
2157
+ if (!trustOnFirstUse && typeof cb !== 'function') {
2158
+ throw new HostKeyError('HOST_KEY_UNKNOWN',
2159
+ `${label} is not in knownHosts; pass trustOnFirstUse: true (or an onHostKey) to pin it`, { actual: fp });
2160
+ }
2161
+ knownHosts.addHost(label, fp);
2162
+ }
1959
2163
  }
1960
2164
 
1961
2165
  // ── Internal: auth handshake ────────────────────────────────────────
@@ -1972,13 +2176,16 @@ export class WshClient {
1972
2176
  * @returns {Promise<string>} Session ID
1973
2177
  * @private
1974
2178
  */
1975
- async #performAuth({ username, keyPair, password, timeout = DEFAULT_AUTH_TIMEOUT } = {}) {
2179
+ async #performAuth({ username, keyPair, password, timeout = DEFAULT_AUTH_TIMEOUT, url, ...hostKeyOpts } = {}) {
1976
2180
  if (!username) throw new Error('username is required');
1977
2181
  if (!keyPair && !password) throw new Error('Either keyPair or password is required');
1978
2182
 
2183
+ this.#hostKey = null;
1979
2184
  try {
1980
2185
  const authMethod = keyPair ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
1981
- await this.#transport.sendControl(hello({ username, authMethod }));
2186
+ const hostKeyNonce = newHostKeyNonce();
2187
+ await this.#transport.sendControl(hello({ username, authMethod, features: [HOST_KEY_NONCE_PREFIX + hostKeyNonce] }));
2188
+ const hostCtx = { ...hostKeyOpts, url, username, nonce: hostKeyNonce };
1982
2189
 
1983
2190
  const firstResponse = await this.#waitForMessage(
1984
2191
  [MSG.SERVER_HELLO, MSG.CHALLENGE, MSG.AUTH_FAIL],
@@ -1995,13 +2202,15 @@ export class WshClient {
1995
2202
  if (firstResponse.type === MSG.SERVER_HELLO) {
1996
2203
  tempSessionId = firstResponse.session_id;
1997
2204
  this.#serverFeatures = firstResponse.features || [];
2205
+ // Waiter first, then the (async) host key check -- see connect().
2206
+ const challengeWait = authMethod === AUTH_METHOD.PUBKEY
2207
+ ? this.#waitForMessage([MSG.CHALLENGE, MSG.AUTH_OK], timeout, 'Timed out waiting for challenge')
2208
+ : null;
2209
+ challengeWait?.catch(() => {});
2210
+ await this.#verifyHostKey(firstResponse, hostCtx);
1998
2211
 
1999
2212
  if (authMethod === AUTH_METHOD.PUBKEY) {
2000
- const challengeMsg = await this.#waitForMessage(
2001
- [MSG.CHALLENGE, MSG.AUTH_OK],
2002
- timeout,
2003
- 'Timed out waiting for challenge'
2004
- );
2213
+ const challengeMsg = await challengeWait;
2005
2214
 
2006
2215
  if (challengeMsg.type === MSG.AUTH_OK) {
2007
2216
  this.#sessionId = challengeMsg.session_id || tempSessionId;
@@ -2025,6 +2234,7 @@ export class WshClient {
2025
2234
  }
2026
2235
  } else if (firstResponse.type === MSG.CHALLENGE) {
2027
2236
  if (!keyPair) throw new Error('Server sent CHALLENGE but no key pair provided');
2237
+ await this.#verifyHostKey(null, hostCtx);
2028
2238
 
2029
2239
  // Challenge carries session_id directly — see connect()'s
2030
2240
  // CHALLENGE branch for why. Keep tempSessionId as a fallback for