@johnhenry/wsh 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -61,22 +61,39 @@ Or via CDN:
61
61
  - **Reverse mode** -- register as a peer (via a signed peer record) and accept incoming connections through a relay
62
62
  - **File transfer** -- scp-like upload/download as `FileChunk` control messages in 64KB chunks
63
63
  - **MCP bridge** -- discover and invoke remote MCP tools through the control channel
64
- - **Session recording** -- own JSON schema (not asciicast v2 -- see note below) recording and playback with seek/pause/resume
64
+ - **Session recording** -- own JSON schema (asciicast-v2-shaped, not a playable asciicast file -- see note below) recording and playback with seek/pause/resume
65
65
  - **Key management** -- IndexedDB storage with OPFS encrypted backup (PBKDF2 + AES-256-GCM)
66
66
  - **98 message types** -- handshake, channel, gateway, guest sharing, compression negotiation, copilot, policy, authorized-key management, and more
67
67
 
68
- > **Session recording is not asciicast v2.** Both `SessionRecorder`
69
- > implementations capture more than asciicast v2's three event codes
70
- > (`o`/`i`/`r`) can represent -- session lifecycle events (`open`/`exit` in
71
- > JS; `Start`/`Exit`/`Snapshot` in Rust) have no asciicast v2 equivalent,
72
- > and dropping them wasn't worth it just to match the format. The JS and
73
- > Rust recorders also use two different, mutually incompatible schemas from
74
- > each other (neither is asciicast v2's newline-delimited-JSON shape).
75
- > `RecordingExport`'s `format: "asciicast"` option is accepted on the wire
76
- > but not yet implemented server-side -- requesting it currently returns
77
- > the same custom JSONL as `format: "jsonl"`. If interop with standard
78
- > asciicast players (`asciinema play`, etc.) is wanted, that needs a real
79
- > (lossy -- lifecycle events would be dropped) export path, not a rename.
68
+ > **Session recording is asciicast-v2-shaped, but not an asciicast file.**
69
+ > The JS `SessionRecorder.toJSON()` export borrows asciicast v2's vocabulary --
70
+ > `version: 2`, `width`, `height`, `timestamp`, `env`, and events as
71
+ > `[seconds, "o" | "i" | "r", data]` -- so it is easy to convert, but it is not
72
+ > something `asciinema play` can read as-is:
73
+ >
74
+ > - it is **one JSON document** with an `events` array; asciicast v2 is
75
+ > newline-delimited JSON (a header line, then one event per line);
76
+ > - the header carries wsh's own `sessionId`, and `width`/`height` follow the
77
+ > latest resize rather than the size at the start;
78
+ > - session lifecycle events (`open`, `exit`) use codes asciicast v2 does not
79
+ > define (the Rust recorder also has `Start`/`Exit`/`Snapshot`, and uses a
80
+ > different, incompatible schema from the JS one);
81
+ > - `RecordingExport`'s `format: "asciicast"` option is accepted on the wire but
82
+ > not implemented server-side -- it returns the same custom JSONL as
83
+ > `format: "jsonl"`.
84
+ >
85
+ > To play a JS recording in asciinema, write the header and the `o`/`i`/`r`
86
+ > events out as lines (lossy: lifecycle events are dropped):
87
+ >
88
+ > ```js
89
+ > const { version, width, height, timestamp, env, events } = recorder.toJSON();
90
+ > const cast = [JSON.stringify({ version, width, height, timestamp, env }),
91
+ > ...events.filter((e) => 'oir'.includes(e[1])).map((e) => JSON.stringify(e))].join('\n') + '\n';
92
+ > // write `cast` to session.cast, then: asciinema play session.cast
93
+ > ```
94
+ >
95
+ > A first-class `.cast` exporter is not provided; the conversion above is the
96
+ > supported path (it is covered by `test/recording-format.test.mjs`).
80
97
 
81
98
  ## Wire Protocol: QMux
82
99
 
@@ -186,6 +203,13 @@ restricted host with no shell. `fs.root` confines every path: `..`, absolute
186
203
  paths and symlinks pointing out of it are refused -- for `write` and `rename`
187
204
  (both paths) exactly as for `read`.
188
205
 
206
+ ### Gateway (TCP and DNS egress from the host)
207
+
208
+ `gateway: { allow: ['broker.lan:1883'], socks: '127.0.0.1:9050' }` makes the Node server answer the gateway opcodes, so a
209
+ client (a browser tab via netway's `GatewayBackend`) can open TCP connections and resolve names from the host. Default
210
+ deny; `allow` takes `"*"`, `"host"` or `"host:port"`. `socks` routes every dial through a SOCKS5 proxy (Tor) with the name
211
+ unresolved. UDP and reverse tunnels are refused.
212
+
189
213
  ### TLS (`wss://`) and host extensions
190
214
 
191
215
  `tls: { cert, key }` (PEM, the options of `https.createServer`) serves the WebSocket listener over TLS on the same
@@ -194,6 +218,50 @@ messages whose `type` is a string, from authenticated connections only. On a rel
194
218
  `relay: { onUnreachable(from, targetFingerprint, request) }` is told when an operator asked for a peer that is not
195
219
  registered (the operator still gets `no such peer`), so the host can reach that peer another way, such as a Web Push.
196
220
 
221
+ ### Pre-upgraded transports: `accept()`
222
+
223
+ `createWshServer()` normally opens its own listener. If your application already owns the HTTP server (or Worker, or
224
+ test harness) and has upgraded the connection itself, hand the socket over instead of bridging it to a per-session
225
+ loopback listener (which any local process could also connect to):
226
+
227
+ ```js
228
+ const wsh = createWshServer({ auth: { authorizedKeys }, exec: true }); // no listen()
229
+ wss.on('connection', (ws, req) => wsh.accept(ws, { remote: { address: req.socket.remoteAddress } }));
230
+ ```
231
+
232
+ `accept(socket, { remote })` takes any WebSocket-like duplex (`send`, `close`, `readyState`, and `on` or
233
+ `addEventListener`): a `ws` socket, a browser/Worker `WebSocket`, a test double. It applies the same auth, features,
234
+ rate limit and session registry as `listen()`'s connections, may be mixed with `listen()`, and resolves to
235
+ `{ close() }`. `remote.address` keys the password-failure throttle (sockets without one share a key). `server.close()`
236
+ drops every accepted socket.
237
+
238
+ ### In-memory host (`@johnhenry/wsh/memory`)
239
+
240
+ A browser-safe host for demos and tests: no sockets, no ports, no Node (nothing in its import graph is `node:*` or `ws`).
241
+
242
+ ```js
243
+ import { createMemoryHost } from '@johnhenry/wsh/memory';
244
+
245
+ const host = createMemoryHost({
246
+ auth: { authorizedKeys: [rawPublicKey] }, // same shapes as createWshServer
247
+ exec: async (command, io) => { await io.write(`ran ${command}\n`); return 0; },
248
+ });
249
+ const client = await host.connect({ username: 'demo', keyPair }); // or { signer } / { password }
250
+ const session = await client.openSession({ type: 'exec', command: 'hi' });
251
+ session.onData = (d) => console.log(new TextDecoder().decode(d)); // "ran hi"
252
+ ```
253
+
254
+ It is the real connection handler speaking QMux over an in-memory socket pair, so the client code you test is the code
255
+ you ship. `host.createClient()` / `host.WebSocket` / `host.accept(socket)` / `createSocketPair()` are the lower-level pieces.
256
+ Supported: `auth`, `exec` (a function; there is no shell), `mcp`, `extensions`. Not available in memory: `pty`, `fs`,
257
+ `gateway`, `relay`, `sessions`, `rpc`, `hostKey` and WebTransport (use `@johnhenry/wsh/server`).
258
+
259
+ ### Signing with a page-held signer
260
+
261
+ `connect()`, `WshClient.exec()` and `connectReverse()` take `signer: { publicKey, sign(data) }` instead of `keyPair`
262
+ (not both): `publicKey` is the raw 32-byte Ed25519 key or a public `CryptoKey`, `sign` returns the 64-byte signature
263
+ (sync or async). For keys that live in a wallet, a hardware token or another realm and are not a `CryptoKeyPair`.
264
+
197
265
  ### WebTransport listener
198
266
 
199
267
  The client's ladder prefers WebTransport (`https://`) and falls back to `wss://`;
@@ -929,7 +997,7 @@ so options the platform gains later need no change here.
929
997
  | `WshMcpBridge` | Remote MCP tool discovery and invocation |
930
998
  | `RpcChannel` / `RpcError` | Typed RPC channel (JSON-RPC 2.0 over a CBOR sequence): `client.openRpc(protocol)`; `RPC_FEATURE`, `rpcProtocolFeature(name)`, `RPC_ERROR`, `CborSequenceDecoder` |
931
999
  | `mcpClientTransport()` / `mcpServerAdapter()` (`/server`) | MCP SDK `Transport` over an `rpc` channel |
932
- | `SessionRecorder` | Record PTY I/O with timestamps (own schema, not asciicast v2) |
1000
+ | `SessionRecorder` | Record PTY I/O with timestamps (asciicast-v2-shaped JSON; convert to play in asciinema, see the note under Session recording) |
933
1001
  | `SessionPlayer` | Replay recordings with original timing |
934
1002
  | `generateKeyPair()` | Create Ed25519 key pair via Web Crypto |
935
1003
  | `sign(privateKey, message)` / `verify(publicKey, signature, message)` | Raw Ed25519 sign / verify. This positional order (WebCrypto's) is the one canonical form across wsh, raijin and browsermesh |
@@ -996,7 +1064,7 @@ than left to be found.
996
1064
  | Attach / resume | `attachSession()`, `resumeSession()`, `WshSession.seq` | `attach_session()`, `resume_session()` | **Wire-unified** (`Attach`/`Resume`/`Presence`); `seq` = cumulative output bytes. `@johnhenry/wsh/server` honours `last_seq` (bounded ring, gap errors) and answers Presence with `channel_id`/`seq`; the Rust `wsh-server` does the same (`rust-v0.2.0`+; fixed 256 KiB ring) |
997
1065
  | Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()`; `@johnhenry/wsh/server`: `relay` option and `createReverseHost()` | `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. The Node relay is default-deny and bridges one operator per peer unless `maxOperatorsPerPeer` is raised (`busy:` reasons say so); E2E and bridged feature gates need `rust-v0.3.0`+ on the Rust side; several operators per peer and the signed `KeyExchange` are JS-relay only for now |
998
1066
  | 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 |
999
- | 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 |
1067
+ | Session recording/replay | `SessionRecorder`/`SessionPlayer` (asciicast-v2-shaped JSON) | none | **JS-only** -- no current Rust consumer needs playback; the vocabulary is asciicast v2's, so a Rust port could share it |
1000
1068
  | 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 |
1001
1069
 
1002
1070
  ### Build and test
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/wsh",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
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",
@@ -12,6 +12,10 @@
12
12
  "./server": {
13
13
  "types": "./src/server/index.d.ts",
14
14
  "import": "./src/server/index.mjs"
15
+ },
16
+ "./memory": {
17
+ "types": "./src/memory-host.d.ts",
18
+ "import": "./src/memory-host.mjs"
15
19
  }
16
20
  },
17
21
  "types": "./src/index.d.ts",
package/src/auth.mjs CHANGED
@@ -159,6 +159,65 @@ export async function sign(privateKey, data) {
159
159
  return new Uint8Array(sig);
160
160
  }
161
161
 
162
+ /**
163
+ * A page-held signer: an object that can sign but need not expose a private key.
164
+ * `publicKey` is the raw 32-byte Ed25519 key (or a `CryptoKey`); `sign(data)` returns the
165
+ * 64-byte signature, synchronously or not. For keys held by a wallet, a hardware token, a
166
+ * Worker, or anything else that is not a `CryptoKeyPair`.
167
+ *
168
+ * @typedef {{ publicKey: Uint8Array | CryptoKey, sign: (data: Uint8Array) => Uint8Array | Promise<Uint8Array> }} WshSigner
169
+ */
170
+
171
+ /**
172
+ * Turn `{ keyPair }` or `{ signer }` into one `{ publicKeyRaw, sign }` pair, or `null` when neither is given.
173
+ * Throws a TypeError for both together or for a malformed signer.
174
+ *
175
+ * @param {{ keyPair?: CryptoKeyPair, signer?: WshSigner }} credentials
176
+ * @returns {Promise<{ publicKeyRaw: Uint8Array, sign: (data: Uint8Array) => Promise<Uint8Array> } | null>}
177
+ */
178
+ export async function resolveSigner({ keyPair, signer } = {}) {
179
+ if (keyPair && signer) throw new TypeError('pass either keyPair or signer, not both');
180
+ if (keyPair) {
181
+ return { publicKeyRaw: await exportPublicKeyRaw(keyPair.publicKey), sign: (data) => sign(keyPair.privateKey, data) };
182
+ }
183
+ if (!signer) return null;
184
+ if (typeof signer !== 'object' || typeof signer.sign !== 'function') {
185
+ throw new TypeError('signer must be { publicKey, sign(data) }');
186
+ }
187
+ let publicKeyRaw;
188
+ if (signer.publicKey instanceof Uint8Array) publicKeyRaw = signer.publicKey;
189
+ else if (signer.publicKey && typeof signer.publicKey === 'object' && signer.publicKey.type === 'public') publicKeyRaw = await exportPublicKeyRaw(signer.publicKey);
190
+ if (!publicKeyRaw || publicKeyRaw.byteLength !== 32) {
191
+ throw new TypeError('signer.publicKey must be a raw 32-byte Ed25519 key or a public CryptoKey');
192
+ }
193
+ return {
194
+ publicKeyRaw,
195
+ sign: async (data) => {
196
+ const sig = await signer.sign(data);
197
+ if (!(sig instanceof Uint8Array) || sig.byteLength !== 64) throw new TypeError('signer.sign() must return a 64-byte Ed25519 signature');
198
+ return sig;
199
+ },
200
+ };
201
+ }
202
+
203
+ /**
204
+ * `signChallenge` for a resolved signer (see `resolveSigner`).
205
+ * @returns {Promise<{ signature: Uint8Array, publicKeyRaw: Uint8Array }>}
206
+ */
207
+ export async function signChallengeWith(signer, sessionId, nonce, opts) {
208
+ const transcript = await buildTranscript(sessionId, nonce, opts);
209
+ return { signature: await signer.sign(transcript), publicKeyRaw: signer.publicKeyRaw };
210
+ }
211
+
212
+ /**
213
+ * `signPeerRecord` for a resolved signer (see `resolveSigner`).
214
+ * @returns {Promise<{ signature: Uint8Array, publicKeyRaw: Uint8Array }>}
215
+ */
216
+ export async function signPeerRecordWith(signer, record) {
217
+ const transcript = await buildPeerRecordTranscript(record);
218
+ return { signature: await signer.sign(transcript), publicKeyRaw: signer.publicKeyRaw };
219
+ }
220
+
162
221
  /**
163
222
  * Verify an Ed25519 signature.
164
223
  * @param {CryptoKey} publicKey
package/src/client.mjs CHANGED
@@ -37,7 +37,7 @@ import {
37
37
  sessionGrant as sessionGrantMsg, sessionRevoke as sessionRevokeMsg,
38
38
  isRelayForwardable,
39
39
  } from './messages.mjs';
40
- import { signChallenge, exportPublicKeyRaw, signPeerRecord, verifyPeerRecord, importPublicKeyRaw, fingerprint as computeFingerprint } from './auth.mjs';
40
+ import { resolveSigner, signChallengeWith, signPeerRecordWith, verifyPeerRecord, importPublicKeyRaw, fingerprint as computeFingerprint } from './auth.mjs';
41
41
  import { generateMlKemKeyPair, mlKemEncapsulate, mlKemDecapsulate } from './mlkem.mjs';
42
42
  import { compareBytes, combineHybridSecret, verifyKeyExchangeSignature } from './e2e-exchange.mjs';
43
43
  import { WshSession } from './session.mjs';
@@ -467,7 +467,8 @@ export class WshClient {
467
467
  * @param {string} url - Server URL (https:// for WebTransport, wss:// or ws:// for WebSocket)
468
468
  * @param {object} opts
469
469
  * @param {string} opts.username - Username for authentication
470
- * @param {CryptoKeyPair} [opts.keyPair] - Ed25519 key pair for pubkey auth
470
+ * @param {CryptoKeyPair} [opts.keyPair]
471
+ * @param {import('./auth.mjs').WshSigner} [opts.signer] - Page-held signer `{ publicKey, sign(data) }` instead of `keyPair`.
471
472
  * @param {string} [opts.password] - Password for password auth
472
473
  * @param {'wt'|'ws'|'auto'} [opts.transport] - Force a specific transport
473
474
  * @param {object} [opts.webTransport] - Options forwarded to the
@@ -493,15 +494,16 @@ export class WshClient {
493
494
  * @param {Function} [opts.onHostKey] - Per-connect `onHostKey`.
494
495
  * @returns {Promise<string>} The server-assigned session ID
495
496
  */
496
- async connect(url, { username, keyPair, password, transport: transportHint, webTransport, timeout = DEFAULT_AUTH_TIMEOUT, ...hostKeyOpts } = {}) {
497
+ async connect(url, { username, keyPair, signer, password, transport: transportHint, webTransport, timeout = DEFAULT_AUTH_TIMEOUT, ...hostKeyOpts } = {}) {
497
498
  if (this.#state !== STATE_DISCONNECTED && this.#state !== STATE_CLOSED) {
498
499
  throw new Error(`Client already ${this.#state}`);
499
500
  }
500
501
  if (!username) {
501
502
  throw new Error('username is required');
502
503
  }
503
- if (!keyPair && !password) {
504
- throw new Error('Either keyPair or password is required for authentication');
504
+ const cred = await resolveSigner({ keyPair, signer });
505
+ if (!cred && !password) {
506
+ throw new Error('Either keyPair, signer or password is required for authentication');
505
507
  }
506
508
 
507
509
  this.#state = STATE_CONNECTING;
@@ -519,7 +521,7 @@ export class WshClient {
519
521
  this.#state = STATE_CONNECTED;
520
522
 
521
523
  // ── Auth handshake ────────────────────────────────────────────
522
- const authMethod = keyPair ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
524
+ const authMethod = cred ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
523
525
  const hostKeyNonce = newHostKeyNonce();
524
526
  await transport.sendControl(
525
527
  hello({ username, authMethod, features: [HOST_KEY_NONCE_PREFIX + hostKeyNonce] })
@@ -568,9 +570,8 @@ export class WshClient {
568
570
  // Sign the challenge. Challenge.session_id (not the one from
569
571
  // ServerHello) is authoritative for the transcript — see the
570
572
  // comment in the CHALLENGE-first branch below for why.
571
- const { signature, publicKeyRaw } = await signChallenge(
572
- keyPair.privateKey,
573
- keyPair.publicKey,
573
+ const { signature, publicKeyRaw } = await signChallengeWith(
574
+ cred,
574
575
  challengeMsg.session_id,
575
576
  challengeMsg.nonce,
576
577
  { username }
@@ -595,7 +596,7 @@ export class WshClient {
595
596
  } else if (firstResponse.type === MSG.CHALLENGE) {
596
597
  // Some servers skip SERVER_HELLO and go straight to CHALLENGE.
597
598
  await this.#verifyHostKey(null, hostCtx);
598
- if (authMethod !== AUTH_METHOD.PUBKEY || !keyPair) {
599
+ if (authMethod !== AUTH_METHOD.PUBKEY || !cred) {
599
600
  throw new Error('Server sent CHALLENGE but no key pair was provided');
600
601
  }
601
602
 
@@ -607,9 +608,8 @@ export class WshClient {
607
608
  // tempSessionId as a fallback for this.#sessionId below in case
608
609
  // AUTH_OK's own session_id is ever absent.
609
610
  tempSessionId = firstResponse.session_id;
610
- const { signature, publicKeyRaw } = await signChallenge(
611
- keyPair.privateKey,
612
- keyPair.publicKey,
611
+ const { signature, publicKeyRaw } = await signChallengeWith(
612
+ cred,
613
613
  firstResponse.session_id,
614
614
  firstResponse.nonce,
615
615
  { username }
@@ -996,18 +996,19 @@ export class WshClient {
996
996
  * @param {object} opts
997
997
  * @param {string} opts.username
998
998
  * @param {CryptoKeyPair} [opts.keyPair]
999
+ * @param {import('./auth.mjs').WshSigner} [opts.signer] - Page-held signer `{ publicKey, sign(data) }` instead of `keyPair`.
999
1000
  * @param {string} [opts.password]
1000
1001
  * @param {number} [opts.timeout=60000] - Overall timeout in ms
1001
1002
  * @param {boolean} [opts.primer=true] - See `openSession({ primer })`
1002
1003
  * @returns {Promise<{stdout: Uint8Array, exitCode: number}>}
1003
1004
  */
1004
- static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT, primer = true, ...hostKeyOpts } = {}) {
1005
+ static async exec(url, command, { username, keyPair, signer, password, timeout = DEFAULT_EXEC_TIMEOUT, primer = true, ...hostKeyOpts } = {}) {
1005
1006
  const client = new WshClient();
1006
1007
  const chunks = [];
1007
1008
  let exitCode = -1;
1008
1009
 
1009
1010
  try {
1010
- await client.connect(url, { username, keyPair, password, ...hostKeyOpts });
1011
+ await client.connect(url, { username, keyPair, signer, password, ...hostKeyOpts });
1011
1012
 
1012
1013
  const session = await client.openSession({ type: 'exec', command, primer });
1013
1014
 
@@ -1056,6 +1057,7 @@ export class WshClient {
1056
1057
  * @param {object} opts
1057
1058
  * @param {string} opts.username
1058
1059
  * @param {CryptoKeyPair} [opts.keyPair]
1060
+ * @param {import('./auth.mjs').WshSigner} [opts.signer] - Page-held signer `{ publicKey, sign(data) }` instead of `keyPair`.
1059
1061
  * @param {string} [opts.password]
1060
1062
  * @param {object} [opts.expose] - Capabilities to expose { shell, exec, fs, tools }
1061
1063
  * @param {string} [opts.peerType]
@@ -1069,6 +1071,7 @@ export class WshClient {
1069
1071
  async connectReverse(url, {
1070
1072
  username,
1071
1073
  keyPair,
1074
+ signer,
1072
1075
  password,
1073
1076
  webTransport,
1074
1077
  expose = {},
@@ -1081,7 +1084,7 @@ export class WshClient {
1081
1084
  ...hostKeyOpts
1082
1085
  } = {}) {
1083
1086
  // Authenticate normally first.
1084
- const sessionId = await this.connect(url, { username, keyPair, password, webTransport, ...hostKeyOpts });
1087
+ const sessionId = await this.connect(url, { username, keyPair, signer, password, webTransport, ...hostKeyOpts });
1085
1088
 
1086
1089
  // Build capabilities list from expose options.
1087
1090
  const capabilities = [];
@@ -1091,10 +1094,9 @@ export class WshClient {
1091
1094
  if (expose.tools) capabilities.push('tools');
1092
1095
 
1093
1096
  // Export public key for peer identification.
1097
+ const cred = await resolveSigner({ keyPair, signer });
1094
1098
  let publicKey = null;
1095
- if (keyPair) {
1096
- publicKey = await exportPublicKeyRaw(keyPair.publicKey);
1097
- }
1099
+ if (cred) publicKey = cred.publicKeyRaw;
1098
1100
 
1099
1101
  const effectiveShellBackend = shellBackend || (expose.shell ? 'virtual-shell' : 'exec-only');
1100
1102
  const record = {
@@ -1118,8 +1120,8 @@ export class WshClient {
1118
1120
  // (see auth.mjs's "Signed peer records" section). Only possible with
1119
1121
  // a real identity key, same precondition `publicKey` already had.
1120
1122
  let recordSignature;
1121
- if (keyPair) {
1122
- ({ signature: recordSignature } = await signPeerRecord(keyPair.privateKey, keyPair.publicKey, record));
1123
+ if (cred) {
1124
+ ({ signature: recordSignature } = await signPeerRecordWith(cred, record));
1123
1125
  }
1124
1126
 
1125
1127
  // Register as a reverse peer.
@@ -2360,18 +2362,20 @@ export class WshClient {
2360
2362
  * @param {object} opts
2361
2363
  * @param {string} opts.username
2362
2364
  * @param {CryptoKeyPair} [opts.keyPair]
2365
+ * @param {import('./auth.mjs').WshSigner} [opts.signer] - Page-held signer `{ publicKey, sign(data) }` instead of `keyPair`.
2363
2366
  * @param {string} [opts.password]
2364
2367
  * @param {number} [opts.timeout]
2365
2368
  * @returns {Promise<string>} Session ID
2366
2369
  * @private
2367
2370
  */
2368
- async #performAuth({ username, keyPair, password, timeout = DEFAULT_AUTH_TIMEOUT, url, ...hostKeyOpts } = {}) {
2371
+ async #performAuth({ username, keyPair, signer, password, timeout = DEFAULT_AUTH_TIMEOUT, url, ...hostKeyOpts } = {}) {
2369
2372
  if (!username) throw new Error('username is required');
2370
- if (!keyPair && !password) throw new Error('Either keyPair or password is required');
2373
+ const cred = await resolveSigner({ keyPair, signer });
2374
+ if (!cred && !password) throw new Error('Either keyPair, signer or password is required');
2371
2375
 
2372
2376
  this.#hostKey = null;
2373
2377
  try {
2374
- const authMethod = keyPair ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
2378
+ const authMethod = cred ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
2375
2379
  const hostKeyNonce = newHostKeyNonce();
2376
2380
  await this.#transport.sendControl(hello({ username, authMethod, features: [HOST_KEY_NONCE_PREFIX + hostKeyNonce] }));
2377
2381
  const hostCtx = { ...hostKeyOpts, url, username, nonce: hostKeyNonce };
@@ -2409,8 +2413,8 @@ export class WshClient {
2409
2413
  return this.#sessionId;
2410
2414
  }
2411
2415
 
2412
- const { signature, publicKeyRaw } = await signChallenge(
2413
- keyPair.privateKey, keyPair.publicKey, challengeMsg.session_id, challengeMsg.nonce, { username }
2416
+ const { signature, publicKeyRaw } = await signChallengeWith(
2417
+ cred, challengeMsg.session_id, challengeMsg.nonce, { username }
2414
2418
  );
2415
2419
 
2416
2420
  await this.#transport.sendControl(authMsg({
@@ -2422,7 +2426,7 @@ export class WshClient {
2422
2426
  }));
2423
2427
  }
2424
2428
  } else if (firstResponse.type === MSG.CHALLENGE) {
2425
- if (!keyPair) throw new Error('Server sent CHALLENGE but no key pair provided');
2429
+ if (!cred) throw new Error('Server sent CHALLENGE but no key pair provided');
2426
2430
  await this.#verifyHostKey(null, hostCtx);
2427
2431
 
2428
2432
  // Challenge carries session_id directly — see connect()'s
@@ -2430,8 +2434,8 @@ export class WshClient {
2430
2434
  // this.#sessionId below in case AUTH_OK's own session_id is ever
2431
2435
  // absent.
2432
2436
  tempSessionId = firstResponse.session_id;
2433
- const { signature, publicKeyRaw } = await signChallenge(
2434
- keyPair.privateKey, keyPair.publicKey, firstResponse.session_id, firstResponse.nonce, { username }
2437
+ const { signature, publicKeyRaw } = await signChallengeWith(
2438
+ cred, firstResponse.session_id, firstResponse.nonce, { username }
2435
2439
  );
2436
2440
 
2437
2441
  await this.#transport.sendControl(authMsg({
package/src/index.d.ts CHANGED
@@ -1108,6 +1108,38 @@ export function signChallenge(
1108
1108
  opts?: WshTranscriptOptions,
1109
1109
  ): Promise<{ signature: Uint8Array; publicKeyRaw: Uint8Array }>;
1110
1110
 
1111
+ /**
1112
+ * A signer that need not expose a private key: a wallet, a hardware token, a Worker.
1113
+ * `publicKey` is the raw 32-byte Ed25519 key or a public `CryptoKey`; `sign` returns the 64-byte signature.
1114
+ */
1115
+ export interface WshSigner {
1116
+ publicKey: Uint8Array | CryptoKey;
1117
+ sign(data: Uint8Array): Uint8Array | Promise<Uint8Array>;
1118
+ }
1119
+
1120
+ /** A `keyPair` or `signer` normalized to a raw public key and an async `sign`; `null` when neither is given. */
1121
+ export interface WshResolvedSigner {
1122
+ publicKeyRaw: Uint8Array;
1123
+ sign(data: Uint8Array): Promise<Uint8Array>;
1124
+ }
1125
+
1126
+ /** Normalize `{ keyPair }` or `{ signer }`. Throws a TypeError for both together or a malformed signer. */
1127
+ export function resolveSigner(credentials?: { keyPair?: CryptoKeyPair; signer?: WshSigner }): Promise<WshResolvedSigner | null>;
1128
+
1129
+ /** `signChallenge` for a signer resolved by `resolveSigner`. */
1130
+ export function signChallengeWith(
1131
+ signer: WshResolvedSigner,
1132
+ sessionId: string,
1133
+ nonce: Uint8Array,
1134
+ opts?: WshTranscriptOptions,
1135
+ ): Promise<{ signature: Uint8Array; publicKeyRaw: Uint8Array }>;
1136
+
1137
+ /** `signPeerRecord` for a signer resolved by `resolveSigner`. */
1138
+ export function signPeerRecordWith(
1139
+ signer: WshResolvedSigner,
1140
+ record: WshPeerRecord,
1141
+ ): Promise<{ signature: Uint8Array; publicKeyRaw: Uint8Array }>;
1142
+
1111
1143
  /**
1112
1144
  * Server-side: verify a client's challenge response.
1113
1145
  */
@@ -1385,7 +1417,10 @@ export class SerialQueue<T = unknown> {
1385
1417
  * Provides the same interface as WebTransportTransport so that upper
1386
1418
  * layers (session, client) work identically over either transport.
1387
1419
  */
1388
- export class WebSocketTransport extends WshTransport {}
1420
+ export class WebSocketTransport extends WshTransport {
1421
+ /** `WebSocket`: a constructor to dial with instead of the global one (a test double, `createMemoryHost().WebSocket`). */
1422
+ constructor(options?: { WebSocket?: new (url: string) => any });
1423
+ }
1389
1424
 
1390
1425
  /**
1391
1426
  * @deprecated The old hand-rolled 5-byte mux's frame-type byte values.
@@ -1772,6 +1807,8 @@ export interface WshHostKeyOptions {
1772
1807
  export interface WshConnectOptions extends WshHostKeyOptions {
1773
1808
  username: string;
1774
1809
  keyPair?: CryptoKeyPair;
1810
+ /** A page-held signer instead of `keyPair` (not both). */
1811
+ signer?: WshSigner;
1775
1812
  password?: string;
1776
1813
  transport?: 'wt' | 'ws';
1777
1814
  /**
@@ -1845,6 +1882,8 @@ export interface WshResumeSessionOptions {
1845
1882
  export interface WshConnectReverseOptions {
1846
1883
  username: string;
1847
1884
  keyPair?: CryptoKeyPair;
1885
+ /** A page-held signer instead of `keyPair` (not both). */
1886
+ signer?: WshSigner;
1848
1887
  password?: string;
1849
1888
  /** Forwarded to the underlying `connect()` -- see `WshConnectOptions`. */
1850
1889
  webTransport?: WshWebTransportOptions;
@@ -1871,6 +1910,8 @@ export interface WshConnectReverseOptions {
1871
1910
  export interface WshExecOptions extends WshHostKeyOptions {
1872
1911
  username: string;
1873
1912
  keyPair?: CryptoKeyPair;
1913
+ /** A page-held signer instead of `keyPair` (not both). */
1914
+ signer?: WshSigner;
1874
1915
  password?: string;
1875
1916
  timeout?: number;
1876
1917
  /** See {@link WshOpenSessionOptions.primer}. */
@@ -2542,7 +2583,7 @@ export interface SessionRecordingEntry {
2542
2583
  data: unknown;
2543
2584
  }
2544
2585
 
2545
- /** Asciicast v2 export format. */
2586
+ /** The `toJSON()` export: asciicast-v2-shaped (not a playable asciicast file -- one JSON document, plus `sessionId` and `open`/`exit` events). */
2546
2587
  export interface SessionRecordingJSON {
2547
2588
  version: 2;
2548
2589
  width: number;
@@ -2580,7 +2621,7 @@ export class SessionRecorder {
2580
2621
  record(type: SessionRecordingEventType | string, data: unknown): void;
2581
2622
 
2582
2623
  /**
2583
- * Export the recording as a JSON-serializable object (asciicast v2 format).
2624
+ * Export the recording as a JSON-serializable object (asciicast-v2-shaped; see the README for converting it to a `.cast` file).
2584
2625
  */
2585
2626
  toJSON(): SessionRecordingJSON;
2586
2627
 
package/src/index.mjs CHANGED
@@ -47,6 +47,7 @@ export {
47
47
  generateKeyPair, exportPublicKeyRaw, exportPublicKeySSH,
48
48
  importPublicKeyRaw, exportPrivateKeyPKCS8, importPrivateKeyPKCS8,
49
49
  sign, verify, buildTranscript, signChallenge, verifyChallenge,
50
+ resolveSigner, signChallengeWith, signPeerRecordWith,
50
51
  buildPeerRecordTranscript, signPeerRecord, verifyPeerRecord,
51
52
  fingerprint, shortFingerprint, generateNonce,
52
53
  podId, fingerprintToPodId, podIdToFingerprint,
@@ -0,0 +1,48 @@
1
+ import type { WshClient } from './index.js';
2
+ import type { WshSigner } from './index.js';
3
+ import type {
4
+ WshServerAuthOptions, WshServerPrincipal, WshServerMcpOptions, WshExecRunner, WshServerOptions,
5
+ WshAcceptableSocket, WshAcceptOptions,
6
+ } from './server/index.js';
7
+
8
+ export interface WshMemoryHostOptions {
9
+ /** As for `createWshServer`. Omitted = refuse everyone. */
10
+ auth?: WshServerAuthOptions | ((who: WshServerPrincipal) => boolean | Promise<boolean>);
11
+ /** `run(command, io)`; there is no shell in memory, so there is no `true`. */
12
+ exec?: WshExecRunner | { run: WshExecRunner; timeoutMs?: number; clientEnv?: boolean };
13
+ mcp?: WshServerMcpOptions;
14
+ extensions?: WshServerOptions['extensions'];
15
+ bindTimeoutMs?: number;
16
+ onLog?: (line: string) => void;
17
+ }
18
+
19
+ /** Credentials for `host.connect()`: the options of `WshClient.connect()` minus the URL. */
20
+ export interface WshMemoryConnectOptions {
21
+ username: string;
22
+ keyPair?: CryptoKeyPair;
23
+ signer?: WshSigner;
24
+ password?: string;
25
+ timeout?: number;
26
+ }
27
+
28
+ export interface WshMemoryHost {
29
+ /** The URL to give `client.connect()`; the host ignores it. */
30
+ readonly url: string;
31
+ /** A `WebSocket` constructor that dials this host. */
32
+ readonly WebSocket: new (url?: string) => any;
33
+ /** Serve any pre-upgraded socket (see `createSocketPair()`). */
34
+ accept(socket: WshAcceptableSocket, options?: WshAcceptOptions): Promise<{ close(): void }>;
35
+ socketPair: typeof createSocketPair;
36
+ /** A `WshClient` whose transport is this host: `await client.connect(host.url, { username, keyPair, transport: 'ws' })`. */
37
+ createClient(options?: ConstructorParameters<typeof WshClient>[0]): WshClient;
38
+ /** `createClient()` and `connect()` in one. */
39
+ connect(credentials: WshMemoryConnectOptions): Promise<WshClient>;
40
+ /** Drop every connection. */
41
+ close(): Promise<void>;
42
+ }
43
+
44
+ /** Two connected in-memory WebSocket-like sockets. */
45
+ export function createSocketPair(): [WshAcceptableSocket & EventTarget, WshAcceptableSocket & EventTarget];
46
+
47
+ /** A browser-safe wsh host in this JavaScript realm: no sockets, no Node. */
48
+ export function createMemoryHost(options?: WshMemoryHostOptions): WshMemoryHost;