@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/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
 
@@ -63,6 +68,13 @@ const DEFAULT_OPEN_TIMEOUT = 10_000; // ms
63
68
  */
64
69
  export const MCP_CALL_ID_FEATURE = 'mcp-call-id';
65
70
 
71
+ /**
72
+ * ServerHello feature: the host discovers a client-opened exec data stream
73
+ * on its own (the transport announces it), so the client must NOT write the
74
+ * one-byte "primer" -- against such a host it would reach the process's stdin.
75
+ */
76
+ export const STREAM_ANNOUNCE_FEATURE = 'stream-announce';
77
+
66
78
  /**
67
79
  * Reject a missing argument that maps to a `required: true` wire field.
68
80
  *
@@ -168,6 +180,28 @@ async function combineHybridSecret(x25519Bits, kemSharedSecret) {
168
180
  return new Uint8Array(bits);
169
181
  }
170
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
+
171
205
  // ── Client class ──────────────────────────────────────────────────────
172
206
 
173
207
  export class WshClient {
@@ -194,6 +228,16 @@ export class WshClient {
194
228
  /** @type {import('./transport.mjs').WshTransport|null} Active transport. */
195
229
  #transport = null;
196
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
+
197
241
  /** @type {{ wt: () => import('./transport.mjs').WshTransport, ws: () => import('./transport.mjs').WshTransport }} */
198
242
  #transportFactories;
199
243
 
@@ -402,6 +446,18 @@ export class WshClient {
402
446
  return this.#serverFeatures.includes(name);
403
447
  }
404
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
+
405
461
  // ── Connection ──────────────────────────────────────────────────────
406
462
 
407
463
  /**
@@ -422,9 +478,21 @@ export class WshClient {
422
478
  * fine), or base64. Ignored by the WebSocket rung, which has no
423
479
  * equivalent mechanism.
424
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`.
425
493
  * @returns {Promise<string>} The server-assigned session ID
426
494
  */
427
- 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 } = {}) {
428
496
  if (this.#state !== STATE_DISCONNECTED && this.#state !== STATE_CLOSED) {
429
497
  throw new Error(`Client already ${this.#state}`);
430
498
  }
@@ -441,6 +509,7 @@ export class WshClient {
441
509
  this.#waiters.clear();
442
510
  this.#sessionId = null;
443
511
  this.#authToken = null;
512
+ this.#hostKey = null;
444
513
 
445
514
  try {
446
515
  // ── Select and connect transport ──────────────────────────────
@@ -450,9 +519,11 @@ export class WshClient {
450
519
 
451
520
  // ── Auth handshake ────────────────────────────────────────────
452
521
  const authMethod = keyPair ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
522
+ const hostKeyNonce = newHostKeyNonce();
453
523
  await transport.sendControl(
454
- hello({ username, authMethod })
524
+ hello({ username, authMethod, features: [HOST_KEY_NONCE_PREFIX + hostKeyNonce] })
455
525
  );
526
+ const hostCtx = { ...hostKeyOpts, url, username, nonce: hostKeyNonce };
456
527
 
457
528
  // Wait for SERVER_HELLO (which may include a session ID directly) or CHALLENGE.
458
529
  const firstResponse = await this.#waitForMessage(
@@ -471,14 +542,18 @@ export class WshClient {
471
542
  // Server may proceed directly to auth if it accepted the hello.
472
543
  tempSessionId = firstResponse.session_id;
473
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);
474
554
 
475
- // If pubkey auth, we still need a challenge.
476
555
  if (authMethod === AUTH_METHOD.PUBKEY) {
477
- const challengeMsg = await this.#waitForMessage(
478
- [MSG.CHALLENGE, MSG.AUTH_OK],
479
- timeout,
480
- 'Auth handshake timed out waiting for challenge'
481
- );
556
+ const challengeMsg = await challengeWait;
482
557
 
483
558
  if (challengeMsg.type === MSG.AUTH_OK) {
484
559
  // Server accepted without challenge (e.g. trusted key).
@@ -518,6 +593,7 @@ export class WshClient {
518
593
  }
519
594
  } else if (firstResponse.type === MSG.CHALLENGE) {
520
595
  // Some servers skip SERVER_HELLO and go straight to CHALLENGE.
596
+ await this.#verifyHostKey(null, hostCtx);
521
597
  if (authMethod !== AUTH_METHOD.PUBKEY || !keyPair) {
522
598
  throw new Error('Server sent CHALLENGE but no key pair was provided');
523
599
  }
@@ -587,9 +663,14 @@ export class WshClient {
587
663
  * @param {number} [opts.rows=24] - Initial terminal rows
588
664
  * @param {object} [opts.env] - Environment variables
589
665
  * @param {number} [opts.timeout] - Timeout in ms
666
+ * @param {boolean} [opts.primer=true] - For stream-mode `exec` sessions: write
667
+ * the one-byte "primer" some hosts need to discover the client-opened data
668
+ * stream (without it they never bind the stream and drop all output).
669
+ * Skipped automatically against a host advertising `stream-announce`; pass
670
+ * `false` to never send it (e.g. a host that forwards stdin verbatim).
590
671
  * @returns {Promise<WshSession>}
591
672
  */
592
- async openSession({ type = 'pty', command, cols = 80, rows = 24, env, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
673
+ async openSession({ type = 'pty', command, cols = 80, rows = 24, env, timeout = DEFAULT_OPEN_TIMEOUT, primer = true } = {}) {
593
674
  this.#assertAuthenticated('openSession');
594
675
  const requestedChannelId = this._nextChannelId();
595
676
 
@@ -605,6 +686,7 @@ export class WshClient {
605
686
  const entry = {
606
687
  mode: 'session',
607
688
  kind: type,
689
+ primer,
608
690
  requestedChannelId,
609
691
  resolve,
610
692
  reject,
@@ -842,17 +924,18 @@ export class WshClient {
842
924
  * @param {CryptoKeyPair} [opts.keyPair]
843
925
  * @param {string} [opts.password]
844
926
  * @param {number} [opts.timeout=60000] - Overall timeout in ms
927
+ * @param {boolean} [opts.primer=true] - See `openSession({ primer })`
845
928
  * @returns {Promise<{stdout: Uint8Array, exitCode: number}>}
846
929
  */
847
- static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT } = {}) {
930
+ static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT, primer = true, ...hostKeyOpts } = {}) {
848
931
  const client = new WshClient();
849
932
  const chunks = [];
850
933
  let exitCode = -1;
851
934
 
852
935
  try {
853
- await client.connect(url, { username, keyPair, password });
936
+ await client.connect(url, { username, keyPair, password, ...hostKeyOpts });
854
937
 
855
- const session = await client.openSession({ type: 'exec', command });
938
+ const session = await client.openSession({ type: 'exec', command, primer });
856
939
 
857
940
  await new Promise((resolve, reject) => {
858
941
  const timer = setTimeout(() => {
@@ -921,9 +1004,10 @@ export class WshClient {
921
1004
  supportsReplay,
922
1005
  supportsEcho,
923
1006
  supportsTermSync,
1007
+ ...hostKeyOpts
924
1008
  } = {}) {
925
1009
  // Authenticate normally first.
926
- const sessionId = await this.connect(url, { username, keyPair, password, webTransport });
1010
+ const sessionId = await this.connect(url, { username, keyPair, password, webTransport, ...hostKeyOpts });
927
1011
 
928
1012
  // Build capabilities list from expose options.
929
1013
  const capabilities = [];
@@ -1705,15 +1789,57 @@ export class WshClient {
1705
1789
 
1706
1790
  /**
1707
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
+ *
1708
1804
  * @param {string} op - Operation: 'stat', 'list', 'read', 'write', 'mkdir', 'remove', 'rename'
1709
1805
  * @param {string} path - File path
1710
- * @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
1711
1807
  * @param {number} [timeout=10000]
1712
1808
  * @returns {Promise<object>} FileResult response
1713
1809
  */
1714
1810
  async fileOperation(op, path, opts = {}, timeout = DEFAULT_OPEN_TIMEOUT) {
1715
1811
  this.#assertAuthenticated('fileOperation');
1716
1812
  const channelId = this._nextChannelId();
1813
+
1814
+ if (op === 'write' || op === 'rename') {
1815
+ const feature = op === 'write' ? 'file-write' : 'file-rename';
1816
+ if (!this.hasFeature(feature)) throw new Error(`this host does not support file ${op} (no "${feature}" feature)`);
1817
+ let data;
1818
+ if (op === 'write') {
1819
+ data = typeof opts.data === 'string' ? new TextEncoder().encode(opts.data) : opts.data;
1820
+ if (!(data instanceof Uint8Array)) throw new TypeError('fileWrite: data must be a string or Uint8Array');
1821
+ } else {
1822
+ if (typeof opts.newPath !== 'string' || !opts.newPath) throw new TypeError('fileRename: newPath is required');
1823
+ data = new TextEncoder().encode(opts.newPath);
1824
+ }
1825
+ // Registered before sending: the host may fail fast (read-only, too big)
1826
+ // and answer before the last chunk goes out.
1827
+ const result = this.#waitForMessage([MSG.FILE_RESULT], timeout, `Timed out waiting for file ${op} result`);
1828
+ result.catch(() => {});
1829
+ await this.#transport.sendControl(fileOpMsg({
1830
+ channelId, op, path, offset: op === 'write' ? opts.offset : undefined, length: data.byteLength,
1831
+ }));
1832
+ let off = 0;
1833
+ do {
1834
+ const end = Math.min(off + FILE_CHUNK_SIZE, data.byteLength);
1835
+ await this.#transport.sendControl(fileChunkMsg({
1836
+ channelId, offset: off, data: data.subarray(off, end), isFinal: end >= data.byteLength, totalSize: data.byteLength,
1837
+ }));
1838
+ off = end;
1839
+ } while (off < data.byteLength);
1840
+ return result;
1841
+ }
1842
+
1717
1843
  await this.#transport.sendControl(
1718
1844
  fileOpMsg({ channelId, op, path, offset: opts.offset, length: opts.length })
1719
1845
  );
@@ -1731,13 +1857,13 @@ export class WshClient {
1731
1857
  /** Read a remote file. */
1732
1858
  async fileRead(path, offset, length, timeout) { return this.fileOperation('read', path, { offset, length }, timeout); }
1733
1859
  /** Write to a remote file. */
1734
- async fileWrite(path, data, offset, timeout) { return this.fileOperation('write', path, { offset }, timeout); }
1860
+ async fileWrite(path, data, offset, timeout) { return this.fileOperation('write', path, { data, offset }, timeout); }
1735
1861
  /** Create a remote directory. */
1736
1862
  async fileMkdir(path, timeout) { return this.fileOperation('mkdir', path, {}, timeout); }
1737
1863
  /** Remove a remote file or directory. */
1738
1864
  async fileRemove(path, timeout) { return this.fileOperation('remove', path, {}, timeout); }
1739
1865
  /** Rename a remote file or directory. */
1740
- async fileRename(oldPath, newPath, timeout) { return this.fileOperation('rename', oldPath, {}, timeout); }
1866
+ async fileRename(oldPath, newPath, timeout) { return this.fileOperation('rename', oldPath, { newPath }, timeout); }
1741
1867
 
1742
1868
  // ── Authorized-key management (wsh #59) ───────────────────────────
1743
1869
 
@@ -1941,7 +2067,95 @@ export class WshClient {
1941
2067
  // Proceed with auth using the same logic.
1942
2068
  // We can't call this.connect() directly because it would create a new
1943
2069
  // transport, so we duplicate the auth portion.
1944
- return this.#performAuth(opts);
2070
+ return this.#performAuth({ ...opts, url });
2071
+ }
2072
+
2073
+ // ── Internal: host key (TOFU / pinning) ─────────────────────────────
2074
+
2075
+ /**
2076
+ * Check the host's advertised key (see host-key.mjs) against the caller's
2077
+ * policy. Runs before any credential leaves this client. Throws a
2078
+ * `HostKeyError`; the surrounding handshake `catch` closes the transport.
2079
+ *
2080
+ * @param {object|null} serverHello - ServerHello, or null when the server went straight to CHALLENGE
2081
+ * @param {object} ctx - { url, username, nonce, expectHostKey, knownHosts, hostLabel, trustOnFirstUse, onHostKey }
2082
+ * @private
2083
+ */
2084
+ async #verifyHostKey(serverHello, ctx) {
2085
+ const { expectHostKey, knownHosts, trustOnFirstUse, nonce, username } = ctx;
2086
+ const wantsCheck = Boolean(expectHostKey || knownHosts);
2087
+ const advert = serverHello ? findHostKeyAdvert(serverHello.features) : null;
2088
+
2089
+ if (!advert) {
2090
+ if (wantsCheck) {
2091
+ throw new HostKeyError('HOST_KEY_MISSING',
2092
+ 'host key required (expectHostKey/knownHosts) but the server presented none; refusing before sending credentials');
2093
+ }
2094
+ return;
2095
+ }
2096
+
2097
+ const { key, sig } = advert;
2098
+ if (!key || key.byteLength !== 32 || !sig) {
2099
+ throw new HostKeyError('HOST_KEY_INVALID', 'server advertised a malformed host key or no proof of possession');
2100
+ }
2101
+ let ok = false;
2102
+ let fp;
2103
+ try {
2104
+ fp = await computeFingerprint(key);
2105
+ ok = await verifySignature(
2106
+ await importPublicKeyRaw(key), sig,
2107
+ hostKeyProofMessage({ sessionId: serverHello.session_id, clientNonce: nonce, username }),
2108
+ );
2109
+ } catch { ok = false; }
2110
+ if (!ok) throw new HostKeyError('HOST_KEY_INVALID', 'host key proof does not verify (not signed over this connection)');
2111
+ if (serverHello.host_fingerprint && serverHello.host_fingerprint !== fp) {
2112
+ throw new HostKeyError('HOST_KEY_INVALID', 'ServerHello.host_fingerprint does not match the advertised host key');
2113
+ }
2114
+
2115
+ let openssh = '';
2116
+ try { openssh = await exportPublicKeySSH(await importPublicKeyRaw(key)); } catch { /* informational only */ }
2117
+ const info = { fingerprint: fp, publicKey: key, openssh, status: 'unpinned' };
2118
+ this.#hostKey = info;
2119
+
2120
+ if (expectHostKey) {
2121
+ const want = await normalizeExpectedHostKey(expectHostKey);
2122
+ if (want !== fp) {
2123
+ throw new HostKeyError('HOST_KEY_MISMATCH',
2124
+ `host key mismatch: expected ${want}, host presented ${fp}`, { expected: want, actual: fp });
2125
+ }
2126
+ info.status = 'pinned';
2127
+ }
2128
+
2129
+ let label = ctx.hostLabel;
2130
+ if (knownHosts) {
2131
+ if (!label) {
2132
+ try { label = new URL(ctx.url).host; } catch { label = String(ctx.url); }
2133
+ }
2134
+ const v = knownHosts.verifyHost(label, fp);
2135
+ if (v.status === 'changed') {
2136
+ throw new HostKeyError('HOST_KEY_MISMATCH',
2137
+ `HOST KEY CHANGED for ${label}: pinned ${v.expected}, host presented ${fp} (possible impersonation)`,
2138
+ { expected: v.expected, actual: fp });
2139
+ }
2140
+ info.status = v.status; // 'known' | 'unknown'
2141
+ }
2142
+
2143
+ const cb = ctx.onHostKey ?? this.onHostKey;
2144
+ if (typeof cb === 'function') {
2145
+ let verdict;
2146
+ try { verdict = await cb({ ...info }); } catch (e) {
2147
+ throw new HostKeyError('HOST_KEY_REJECTED', `onHostKey threw: ${e?.message ?? e}`);
2148
+ }
2149
+ if (verdict === false) throw new HostKeyError('HOST_KEY_REJECTED', 'host key rejected by onHostKey');
2150
+ }
2151
+
2152
+ if (knownHosts && info.status === 'unknown') {
2153
+ if (!trustOnFirstUse && typeof cb !== 'function') {
2154
+ throw new HostKeyError('HOST_KEY_UNKNOWN',
2155
+ `${label} is not in knownHosts; pass trustOnFirstUse: true (or an onHostKey) to pin it`, { actual: fp });
2156
+ }
2157
+ knownHosts.addHost(label, fp);
2158
+ }
1945
2159
  }
1946
2160
 
1947
2161
  // ── Internal: auth handshake ────────────────────────────────────────
@@ -1958,13 +2172,16 @@ export class WshClient {
1958
2172
  * @returns {Promise<string>} Session ID
1959
2173
  * @private
1960
2174
  */
1961
- async #performAuth({ username, keyPair, password, timeout = DEFAULT_AUTH_TIMEOUT } = {}) {
2175
+ async #performAuth({ username, keyPair, password, timeout = DEFAULT_AUTH_TIMEOUT, url, ...hostKeyOpts } = {}) {
1962
2176
  if (!username) throw new Error('username is required');
1963
2177
  if (!keyPair && !password) throw new Error('Either keyPair or password is required');
1964
2178
 
2179
+ this.#hostKey = null;
1965
2180
  try {
1966
2181
  const authMethod = keyPair ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
1967
- await this.#transport.sendControl(hello({ username, authMethod }));
2182
+ const hostKeyNonce = newHostKeyNonce();
2183
+ await this.#transport.sendControl(hello({ username, authMethod, features: [HOST_KEY_NONCE_PREFIX + hostKeyNonce] }));
2184
+ const hostCtx = { ...hostKeyOpts, url, username, nonce: hostKeyNonce };
1968
2185
 
1969
2186
  const firstResponse = await this.#waitForMessage(
1970
2187
  [MSG.SERVER_HELLO, MSG.CHALLENGE, MSG.AUTH_FAIL],
@@ -1981,13 +2198,15 @@ export class WshClient {
1981
2198
  if (firstResponse.type === MSG.SERVER_HELLO) {
1982
2199
  tempSessionId = firstResponse.session_id;
1983
2200
  this.#serverFeatures = firstResponse.features || [];
2201
+ // Waiter first, then the (async) host key check -- see connect().
2202
+ const challengeWait = authMethod === AUTH_METHOD.PUBKEY
2203
+ ? this.#waitForMessage([MSG.CHALLENGE, MSG.AUTH_OK], timeout, 'Timed out waiting for challenge')
2204
+ : null;
2205
+ challengeWait?.catch(() => {});
2206
+ await this.#verifyHostKey(firstResponse, hostCtx);
1984
2207
 
1985
2208
  if (authMethod === AUTH_METHOD.PUBKEY) {
1986
- const challengeMsg = await this.#waitForMessage(
1987
- [MSG.CHALLENGE, MSG.AUTH_OK],
1988
- timeout,
1989
- 'Timed out waiting for challenge'
1990
- );
2209
+ const challengeMsg = await challengeWait;
1991
2210
 
1992
2211
  if (challengeMsg.type === MSG.AUTH_OK) {
1993
2212
  this.#sessionId = challengeMsg.session_id || tempSessionId;
@@ -2011,6 +2230,7 @@ export class WshClient {
2011
2230
  }
2012
2231
  } else if (firstResponse.type === MSG.CHALLENGE) {
2013
2232
  if (!keyPair) throw new Error('Server sent CHALLENGE but no key pair provided');
2233
+ await this.#verifyHostKey(null, hostCtx);
2014
2234
 
2015
2235
  // Challenge carries session_id directly — see connect()'s
2016
2236
  // CHALLENGE branch for why. Keep tempSessionId as a fallback for
@@ -2145,8 +2365,20 @@ export class WshClient {
2145
2365
  // so channel-scoped control messages arriving before the stream
2146
2366
  // finishes binding still route correctly.
2147
2367
  this.#transport.openStream().then(
2148
- (stream) => {
2368
+ async (stream) => {
2149
2369
  session._bind(stream.readable, stream.writable);
2370
+ // The transport has already announced the stream, which is all a
2371
+ // current host needs. Older hosts only bind a stream on its first
2372
+ // byte, so exec (which has no stdin to send) writes a one-byte
2373
+ // primer they strip -- unless the host says it needs none.
2374
+ if (pending.kind === 'exec' && pending.primer && !this.hasFeature(STREAM_ANNOUNCE_FEATURE)) {
2375
+ try {
2376
+ await session.write(new Uint8Array([0]));
2377
+ } catch (err) {
2378
+ pending.reject(err);
2379
+ return;
2380
+ }
2381
+ }
2150
2382
  pending.resolve(session);
2151
2383
  },
2152
2384
  (err) => pending.reject(err)
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Host-key identity for wsh (TOFU / pinning). Browser-safe, shared by the stock
3
+ * client and `@johnhenry/wsh/server`.
4
+ *
5
+ * ## Wire shape (an extension carried in existing frames)
6
+ *
7
+ * The wsh-v1 spec has `ServerHello.host_fingerprint` (SHA-256 of the host's
8
+ * Ed25519 key) but no field for the key itself and no proof the server holds
9
+ * it -- a bare fingerprint can be replayed by anyone. Rather than change the
10
+ * spec, the proof rides in the negotiable `features` string lists:
11
+ *
12
+ * - client -> server, in `Hello.features`: `host-key-nonce:<hex>` (fresh, 16 bytes)
13
+ * - server -> client, in `ServerHello.features`:
14
+ * `host-key:<hex>` the raw 32-byte Ed25519 host public key
15
+ * `host-key-sig:<hex>` Ed25519 signature over the proof message below
16
+ * and `ServerHello.host_fingerprint` = fingerprint of that key.
17
+ *
18
+ * Proof message = `"wsh-host-key-proof-v1" 0x00 session_id 0x00 client_nonce 0x00 username`.
19
+ * The client's fresh nonce makes the proof unreplayable; the session id binds
20
+ * it to this ServerHello. It is checked BEFORE any credential is sent, so a
21
+ * password never goes to a host that fails its pin. Peers that don't know the
22
+ * strings ignore them, so nothing breaks against other implementations.
23
+ *
24
+ * Limit: the proof authenticates the ServerHello, not the byte stream -- on a
25
+ * plain `ws://` link an active attacker can relay a genuine ServerHello and
26
+ * then read/alter the rest. Pinning protects you from talking to the WRONG
27
+ * host; confidentiality still needs `wss://`/WebTransport (or a trusted LAN).
28
+ */
29
+
30
+ export const HOST_KEY_NONCE_PREFIX = 'host-key-nonce:';
31
+ export const HOST_KEY_PREFIX = 'host-key:';
32
+ export const HOST_KEY_SIG_PREFIX = 'host-key-sig:';
33
+ const PROOF_TAG = 'wsh-host-key-proof-v1';
34
+
35
+ const enc = new TextEncoder();
36
+
37
+ export function toHex(bytes) {
38
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
39
+ }
40
+
41
+ export function fromHex(hex) {
42
+ if (typeof hex !== 'string' || hex.length % 2 || /[^0-9a-f]/i.test(hex)) throw new Error('invalid hex');
43
+ const out = new Uint8Array(hex.length / 2);
44
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
45
+ return out;
46
+ }
47
+
48
+ /** A refused host key. `code` is one of the `HOST_KEY_*` values below. */
49
+ export class HostKeyError extends Error {
50
+ /**
51
+ * @param {'HOST_KEY_MISSING'|'HOST_KEY_INVALID'|'HOST_KEY_MISMATCH'|'HOST_KEY_UNKNOWN'|'HOST_KEY_REJECTED'} code
52
+ * @param {string} message
53
+ * @param {object} [details]
54
+ */
55
+ constructor(code, message, details = {}) {
56
+ super(message);
57
+ this.name = 'HostKeyError';
58
+ this.code = code;
59
+ Object.assign(this, details);
60
+ }
61
+ }
62
+
63
+ /** Fresh client nonce, hex. */
64
+ export function newHostKeyNonce() {
65
+ return toHex(crypto.getRandomValues(new Uint8Array(16)));
66
+ }
67
+
68
+ /** Bytes the host signs / the client verifies. */
69
+ export function hostKeyProofMessage({ sessionId, clientNonce, username }) {
70
+ return enc.encode(`${PROOF_TAG}\0${sessionId}\0`
71
+ + `${typeof clientNonce === 'string' ? clientNonce : toHex(clientNonce)}\0${username}`);
72
+ }
73
+
74
+ /** Pull the client nonce out of `Hello.features` (server side). Null when absent or malformed. */
75
+ export function findClientNonce(features) {
76
+ for (const f of Array.isArray(features) ? features : []) {
77
+ if (typeof f === 'string' && f.startsWith(HOST_KEY_NONCE_PREFIX)) {
78
+ const hex = f.slice(HOST_KEY_NONCE_PREFIX.length);
79
+ if (/^[0-9a-f]{32,128}$/i.test(hex)) return hex.toLowerCase();
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /** Pull the advertised key + signature out of `ServerHello.features` (client side). */
86
+ export function findHostKeyAdvert(features) {
87
+ let key = null;
88
+ let sig = null;
89
+ for (const f of Array.isArray(features) ? features : []) {
90
+ if (typeof f !== 'string') continue;
91
+ try {
92
+ if (f.startsWith(HOST_KEY_SIG_PREFIX)) sig = fromHex(f.slice(HOST_KEY_SIG_PREFIX.length));
93
+ else if (f.startsWith(HOST_KEY_PREFIX)) key = fromHex(f.slice(HOST_KEY_PREFIX.length));
94
+ } catch { /* malformed -> treated as absent, then refused as INVALID by the caller */ }
95
+ }
96
+ return key || sig ? { key, sig } : null;
97
+ }