@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 +83 -15
- package/package.json +5 -1
- package/src/auth.mjs +59 -0
- package/src/client.mjs +33 -29
- package/src/index.d.ts +44 -3
- package/src/index.mjs +1 -0
- package/src/memory-host.d.ts +48 -0
- package/src/memory-host.mjs +175 -0
- package/src/recording.mjs +18 -10
- package/src/server/connection.mjs +10 -4
- package/src/server/constants.mjs +10 -0
- package/src/server/fs.mjs +2 -1
- package/src/server/gateway.mjs +248 -0
- package/src/server/index.d.ts +30 -0
- package/src/server/index.mjs +64 -19
- package/src/server/pty.mjs +1 -1
- package/src/server/sessions.mjs +2 -1
- package/src/server/socket.mjs +73 -0
- package/src/transport-ws.mjs +18 -2
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
|
|
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
|
|
69
|
-
>
|
|
70
|
-
>
|
|
71
|
-
>
|
|
72
|
-
>
|
|
73
|
-
>
|
|
74
|
-
>
|
|
75
|
-
>
|
|
76
|
-
>
|
|
77
|
-
>
|
|
78
|
-
>
|
|
79
|
-
> (
|
|
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 (
|
|
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
|
|
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.
|
|
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 {
|
|
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]
|
|
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
|
-
|
|
504
|
-
|
|
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 =
|
|
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
|
|
572
|
-
|
|
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 || !
|
|
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
|
|
611
|
-
|
|
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 (
|
|
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 (
|
|
1122
|
-
({ signature: recordSignature } = await
|
|
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
|
-
|
|
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 =
|
|
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
|
|
2413
|
-
|
|
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 (!
|
|
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
|
|
2434
|
-
|
|
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
|
-
/**
|
|
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
|
|
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;
|