@johnhenry/wsh 0.18.0 → 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/README.md +123 -15
- package/package.json +1 -1
- package/src/client.mjs +228 -22
- package/src/host-key.mjs +97 -0
- package/src/index.d.ts +70 -2
- package/src/index.mjs +1 -0
- package/src/server/auth.mjs +57 -0
- package/src/server/connection.mjs +119 -7
- package/src/server/fs.mjs +46 -6
- package/src/server/host-key.mjs +66 -0
- package/src/server/index.d.ts +38 -0
- package/src/server/index.mjs +27 -4
package/README.md
CHANGED
|
@@ -167,8 +167,9 @@ const server = createWshServer({
|
|
|
167
167
|
host: '127.0.0.1',
|
|
168
168
|
port: 4422, // 0 = pick a free port
|
|
169
169
|
auth: { authorizedKeys: readFileSync('authorized_keys', 'utf8') }, // ssh-ed25519 lines
|
|
170
|
+
hostKey: { file: '/etc/wsh/host_key' }, // advertise a pinnable host identity (created 0600 on first start)
|
|
170
171
|
exec: true, // opt in: run commands via /bin/sh
|
|
171
|
-
fs: { root: '/srv/share', readOnly: false }, // opt in: list/stat/read/mkdir/remove/upload/download
|
|
172
|
+
fs: { root: '/srv/share', readOnly: false }, // opt in: list/stat/read/write/rename/mkdir/remove/upload/download
|
|
172
173
|
// pty: { spawn: nodePty.spawn }, // opt in: bring your own node-pty
|
|
173
174
|
});
|
|
174
175
|
const { port } = await server.listen(); // later: server.address(), await server.close()
|
|
@@ -176,17 +177,121 @@ const { port } = await server.listen(); // later: server.address(
|
|
|
176
177
|
|
|
177
178
|
Everything that touches the machine -- `exec`, `pty`, `fs` -- is **off unless
|
|
178
179
|
you pass it**, and with no `auth` every connection is refused. `auth` is
|
|
179
|
-
`{ authorizedKeys, authorize }` (a key must be on the
|
|
180
|
-
`authorize({ username, fingerprint, publicKey })` when both are
|
|
181
|
-
just an `authorize` function. `exec` can also be `{ cwd, env,
|
|
182
|
-
timeoutMs, clientEnv }` or a custom `run(command, io)` for a
|
|
183
|
-
with no shell. `fs.root` confines every path: `..`, absolute
|
|
184
|
-
symlinks pointing out of it are refused
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
180
|
+
`{ authorizedKeys, authorize, password, rateLimit }` (a key must be on the
|
|
181
|
+
list and pass `authorize({ username, fingerprint, publicKey })` when both are
|
|
182
|
+
given) or just an `authorize` function. `exec` can also be `{ cwd, env,
|
|
183
|
+
shell, timeoutMs, clientEnv }` or a custom `run(command, io)` for a
|
|
184
|
+
restricted host with no shell. `fs.root` confines every path: `..`, absolute
|
|
185
|
+
paths and symlinks pointing out of it are refused -- for `write` and `rename`
|
|
186
|
+
(both paths) exactly as for `read`.
|
|
187
|
+
|
|
188
|
+
Not implemented (each has a tracking issue): WebTransport, relay/reverse
|
|
189
|
+
mode, attach/resume of server-side sessions, MCP.
|
|
190
|
+
|
|
191
|
+
### Host key (fingerprint / TOFU)
|
|
192
|
+
|
|
193
|
+
Give the server an identity and the stock client can pin it:
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
// server
|
|
197
|
+
const server = createWshServer({ auth, hostKey: { file: '/etc/wsh/host_key' } });
|
|
198
|
+
await server.listen();
|
|
199
|
+
server.hostKey(); // { fingerprint, publicKey, openssh } -- publish this out of band
|
|
200
|
+
|
|
201
|
+
// client
|
|
202
|
+
import { WshClient, WshKnownHosts, HostKeyError } from '@johnhenry/wsh';
|
|
203
|
+
const client = new WshClient();
|
|
204
|
+
await client.connect(url, { username, keyPair, expectHostKey: fingerprint }); // pin; refuses on mismatch
|
|
205
|
+
// or trust-on-first-use, remembered in a store:
|
|
206
|
+
await client.connect(url, { username, keyPair, knownHosts: new WshKnownHosts(), trustOnFirstUse: true });
|
|
207
|
+
client.hostKey; // { fingerprint, publicKey, openssh, status: 'pinned' | 'known' | 'unknown' | 'unpinned' }
|
|
208
|
+
client.onHostKey = (hk) => confirm(`Trust ${hk.fingerprint}?`); // or per call: connect(..., { onHostKey })
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
- `hostKey` is `{ file }` (a PKCS#8 PEM, created mode 0600 on first start --
|
|
212
|
+
use this), a `CryptoKeyPair`, or `true` (a fresh key every start: every TOFU
|
|
213
|
+
client will see "changed" after a restart; tests and demos only).
|
|
214
|
+
- `expectHostKey` takes a hex fingerprint (`sha256:` prefix fine), raw 32-byte
|
|
215
|
+
key, or an `ssh-ed25519 AAAA...` line. With `knownHosts`, a **changed** key is
|
|
216
|
+
always refused (and never overwrites the pin); an **unseen** host is refused
|
|
217
|
+
unless `trustOnFirstUse` is set or `onHostKey` accepts it. Either option also
|
|
218
|
+
refuses a host that presents no key at all. Refusals are `HostKeyError` with
|
|
219
|
+
a `code` (`HOST_KEY_MISSING | _INVALID | _MISMATCH | _UNKNOWN | _REJECTED`),
|
|
220
|
+
thrown **before any credential -- signature or password -- is sent**.
|
|
221
|
+
- `WshClient.exec()` and `connectReverse()` accept the same options.
|
|
222
|
+
- `WshKnownHosts` defaults to `localStorage`, which in Node is not a persistent file store: for a real TOFU file pass `new WshKnownHosts({ storage })` with a `getItem`/`setItem`/`removeItem` object you back with a file.
|
|
223
|
+
- Wire shape: the spec's `ServerHello.host_fingerprint` is populated, and
|
|
224
|
+
because a bare fingerprint proves nothing (anyone can repeat it) the key and
|
|
225
|
+
a signature ride in `features` strings: the client sends `host-key-nonce:<hex>`
|
|
226
|
+
in `Hello`; the server answers `host-key:<hex>` and `host-key-sig:<hex>`
|
|
227
|
+
(Ed25519 over a tag, the session id, the client's nonce and the username).
|
|
228
|
+
The fresh nonce makes the proof unreplayable. Hosts that don't know these
|
|
229
|
+
strings ignore them (the Rust `wsh-server` advertises no key, so a pin
|
|
230
|
+
against it is refused with `HOST_KEY_MISSING`, never silently skipped).
|
|
231
|
+
- **What it does not give you:** the proof authenticates the ServerHello, not
|
|
232
|
+
the byte stream. Over plain `ws://` an active attacker can relay a genuine
|
|
233
|
+
ServerHello and then read or alter the rest. Pinning keeps you from talking
|
|
234
|
+
to the wrong host; confidentiality still needs `wss://` or a link you trust.
|
|
235
|
+
|
|
236
|
+
### Password auth
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
createWshServer({
|
|
240
|
+
auth: {
|
|
241
|
+
password: async (username, password) => verifyAgainstYourHashes(username, password),
|
|
242
|
+
// authorizedKeys / authorize may be given too: both methods are then served.
|
|
243
|
+
rateLimit: { maxFailures: 5, windowMs: 60_000, lockoutMs: 60_000, failureDelayMs: 250 },
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
await client.connect(url, { username, password });
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The wire already has it (`Hello.auth_method: 'password'` then `Auth{ method:
|
|
250
|
+
'password', password }`, no challenge), so no extension is involved -- and
|
|
251
|
+
that is also its limit: the password travels as plain text inside the
|
|
252
|
+
connection, so serve password logins over `wss://` (TLS-terminating proxy)
|
|
253
|
+
or a link you trust, and pin the host key so it is only ever sent to the right
|
|
254
|
+
host. Use a constant-time comparison against stored hashes in the callback,
|
|
255
|
+
never `===` on plaintext. A host with only `password` refuses key logins and
|
|
256
|
+
vice versa; with neither configured every connection is refused.
|
|
257
|
+
|
|
258
|
+
Failures are throttled per peer address (override with `rateLimit.key`, e.g.
|
|
259
|
+
read `x-forwarded-for` from `headers` behind a proxy): each connection gets one
|
|
260
|
+
guess, a failed one is answered after `failureDelayMs`, and after
|
|
261
|
+
`maxFailures` in `windowMs` the caller is locked out for `lockoutMs` -- the
|
|
262
|
+
callback is not even consulted during lockout, so the right password does not
|
|
263
|
+
get through either. The counter is in-process memory and per server.
|
|
264
|
+
|
|
265
|
+
### Files: write and rename
|
|
266
|
+
|
|
267
|
+
`client.fileWrite(path, data, offset?)` and `client.fileRename(from, to)` work
|
|
268
|
+
against the server's `fs` (earlier versions of the client sent neither the
|
|
269
|
+
bytes nor the new path). Without `offset`, `fileWrite` replaces the file
|
|
270
|
+
(creating it); with one, it writes in place at that byte offset without
|
|
271
|
+
truncating. `fileRename` refuses to overwrite an existing destination, to move
|
|
272
|
+
a directory into itself, or to touch the root. Both honour `readOnly` and
|
|
273
|
+
`maxFileBytes`, and a failure comes back as `success: false` with an
|
|
274
|
+
`error_message`.
|
|
275
|
+
|
|
276
|
+
`FileOp` has no field for the bytes or the destination, so both use
|
|
277
|
+
spec-conformant frames only: the `FileOp` is followed by `FileChunk` frame(s) on
|
|
278
|
+
the same channel id (for `rename`, one chunk holding the UTF-8 destination).
|
|
279
|
+
The host advertises `file-write` / `file-rename` in `ServerHello`; the client
|
|
280
|
+
throws instead of sending to a host without them (the Rust `wsh-server`
|
|
281
|
+
refuses these ops).
|
|
282
|
+
|
|
283
|
+
### Compatibility: announce vs. primer
|
|
284
|
+
|
|
285
|
+
An exec session's output flows on a second, client-opened stream, and a QMux
|
|
286
|
+
stream is invisible on the wire until its first byte. Two mechanisms exist for
|
|
287
|
+
the host to find it:
|
|
288
|
+
|
|
289
|
+
| Client | Host | What happens |
|
|
290
|
+
|---|---|---|
|
|
291
|
+
| Current stock client | `@johnhenry/wsh/server` (advertises `stream-announce`) | The client's transport announces the stream with an empty STREAM frame. **No primer is written.** |
|
|
292
|
+
| Current stock client | Host without `stream-announce` (e.g. the Rust `wsh-server`, older vendored hosts) | The client writes a one-byte primer (`0x00`) on the new stream so the host sees it; that host strips it. `primer: false` disables this (then such a host never binds the stream and the output is lost). |
|
|
293
|
+
| A client that primes anyway (older stock client, `primer` forced, other implementations) | `@johnhenry/wsh/server` | **The leading `0x00` is dropped:** if the first data on an exec stream is exactly one `0x00` byte it is discarded, never forwarded to the process's stdin. Only that first chunk is inspected; a later `0x00`, or a first chunk that is longer than one byte, is delivered untouched. The cost: a client whose genuine first stdin chunk is a lone NUL loses it. |
|
|
294
|
+
| A client that neither announces nor primes | `@johnhenry/wsh/server` | The host waits `bindTimeoutMs` (default 3000) for the stream, then drops the output and logs it. |
|
|
190
295
|
|
|
191
296
|
## Attach and Resume
|
|
192
297
|
|
|
@@ -349,7 +454,7 @@ than left to be found.
|
|
|
349
454
|
| Upload/download | `WshClient.upload()`/`.download()` | `file_transfer::upload()`/`download()`, `wsh scp` | **Wire-unified**: `FileChunk` over a `'file'`-kind channel (wsh #13) |
|
|
350
455
|
| Remove a remote file | `WshClient.fileRemove()` | `file_transfer::remove()`, `wsh sftp`'s `rm` | **Wire-unified**: `FileOp`/`FileResult` (`op: "remove"`) -- refused today by every `wsh-server` release ("not yet implemented"), the same refusal on both sides |
|
|
351
456
|
| Install an authorized key | `WshClient.addAuthorizedKey()` | `wsh_client::WshClient::add_authorized_key()`, `wsh copy-id` | **Wire-unified** (wsh #59): `AuthorizedKeyAdd`/`AuthorizedKeyResult`, replacing a Rust-CLI-only shell command with a message every implementation can send |
|
|
352
|
-
| Host identity / TOFU | `WshKnownHosts` (localStorage-backed) | `KnownHosts`/`HostStatus` (`~/.wsh/known_hosts`-backed) | **Record unified, policy is not** (wsh #59): both pin `ServerHello.host_fingerprint`, a
|
|
457
|
+
| Host identity / TOFU | `WshKnownHosts` (localStorage-backed) | `KnownHosts`/`HostStatus` (`~/.wsh/known_hosts`-backed) | **Record unified, policy is not** (wsh #59): both pin `ServerHello.host_fingerprint`, which `@johnhenry/wsh/server` populates (with a proof of possession, [Host key](#host-key-fingerprint--tofu)) but no Rust `wsh-server` release does yet (see [Security](#security-model)). *When* to trust, prompt, or persist is deliberately left per-implementation -- a browser and a CLI have different UX for "first time seeing this host" |
|
|
353
458
|
| Interactive shell UI | none -- this SDK is a protocol client, not a terminal emulator; pair with xterm.js/ghostty-web | `wsh connect`, `wsh sftp` (line-oriented REPL) | **Deliberately not unified** -- a browser embeds a terminal widget the host page owns; a CLI process owns its own TTY |
|
|
354
459
|
| Reverse-connect / relay peer | `connectReverse()`, `trustRelayPeer()` | `wsh reverse`, `wsh agent` (persistent, with startup-unit install) | **Wire-unified** (registration, discovery, signed peer records); **daemonization is CLI-only** -- a browser tab cannot be a background OS service |
|
|
355
460
|
| Post-quantum E2E (experimental) | `initiateE2E()` (WebCrypto ML-KEM-768 or `@noble/post-quantum` fallback) | `E2eKeyExchange` (`ml-kem` crate) | **Wire-unified** algorithm and transcript; key material backends differ by platform necessity |
|
|
@@ -443,8 +548,11 @@ out explicitly below rather than left to be discovered.
|
|
|
443
548
|
- **Host identity (TOFU) has a known, named gap (wsh #59).**
|
|
444
549
|
`ServerHello.host_fingerprint` is the spec's formal host-identity slot:
|
|
445
550
|
the SHA-256 fingerprint of a server's persistent Ed25519 host key, meant
|
|
446
|
-
to be pinned across connections the way SSH pins a host key. **
|
|
447
|
-
|
|
551
|
+
to be pinned across connections the way SSH pins a host key. **As of 0.19
|
|
552
|
+
`@johnhenry/wsh/server` populates it** (with a signed proof of possession;
|
|
553
|
+
see [Host key](#host-key-fingerprint--tofu)) and the JS client pins it
|
|
554
|
+
(`expectHostKey`, `knownHosts`) -- but **no Rust `wsh-server` release does
|
|
555
|
+
yet**, and the paragraph below still describes that Rust-side gap -- minting and
|
|
448
556
|
persisting a server host keypair is a separate, security-sensitive
|
|
449
557
|
feature, deliberately not bundled into this change. Both clients ship
|
|
450
558
|
the store this field is for: `WshKnownHosts` (JS, `localStorage`-backed)
|
package/package.json
CHANGED
package/src/client.mjs
CHANGED
|
@@ -40,6 +40,11 @@ import { signChallenge, exportPublicKeyRaw, signPeerRecord, verifyPeerRecord, im
|
|
|
40
40
|
import { generateMlKemKeyPair, mlKemEncapsulate, mlKemDecapsulate } from './mlkem.mjs';
|
|
41
41
|
import { WshSession } from './session.mjs';
|
|
42
42
|
import { cborDecode } from './cbor.mjs';
|
|
43
|
+
import {
|
|
44
|
+
HostKeyError, newHostKeyNonce, hostKeyProofMessage, findHostKeyAdvert,
|
|
45
|
+
HOST_KEY_NONCE_PREFIX,
|
|
46
|
+
} from './host-key.mjs';
|
|
47
|
+
import { verify as verifySignature, exportPublicKeySSH, parseSSHPublicKey, extractRawFromSSHWire } from './auth.mjs';
|
|
43
48
|
|
|
44
49
|
// ── Client states ─────────────────────────────────────────────────────
|
|
45
50
|
|
|
@@ -175,6 +180,28 @@ async function combineHybridSecret(x25519Bits, kemSharedSecret) {
|
|
|
175
180
|
return new Uint8Array(bits);
|
|
176
181
|
}
|
|
177
182
|
|
|
183
|
+
/**
|
|
184
|
+
* `expectHostKey` accepts a hex fingerprint, a raw 32-byte key, or an
|
|
185
|
+
* `ssh-ed25519 AAAA...` line; all normalize to the hex fingerprint.
|
|
186
|
+
* @param {string|Uint8Array} v
|
|
187
|
+
* @returns {Promise<string>}
|
|
188
|
+
*/
|
|
189
|
+
async function normalizeExpectedHostKey(v) {
|
|
190
|
+
if (v instanceof Uint8Array) {
|
|
191
|
+
if (v.byteLength !== 32) throw new TypeError('expectHostKey: a raw key must be 32 bytes');
|
|
192
|
+
return computeFingerprint(v);
|
|
193
|
+
}
|
|
194
|
+
const text = String(v).trim();
|
|
195
|
+
if (/^ssh-ed25519\s/.test(text)) {
|
|
196
|
+
const parsed = parseSSHPublicKey(text);
|
|
197
|
+
if (!parsed) throw new TypeError('expectHostKey: malformed ssh-ed25519 line');
|
|
198
|
+
return computeFingerprint(extractRawFromSSHWire(parsed.data));
|
|
199
|
+
}
|
|
200
|
+
const hex = text.replace(/^sha256:/i, '').replace(/:/g, '').toLowerCase();
|
|
201
|
+
if (!/^[0-9a-f]{64}$/.test(hex)) throw new TypeError('expectHostKey: expected a 64-char hex fingerprint, a raw key, or an ssh-ed25519 line');
|
|
202
|
+
return hex;
|
|
203
|
+
}
|
|
204
|
+
|
|
178
205
|
// ── Client class ──────────────────────────────────────────────────────
|
|
179
206
|
|
|
180
207
|
export class WshClient {
|
|
@@ -201,6 +228,16 @@ export class WshClient {
|
|
|
201
228
|
/** @type {import('./transport.mjs').WshTransport|null} Active transport. */
|
|
202
229
|
#transport = null;
|
|
203
230
|
|
|
231
|
+
/** @type {{fingerprint: string, publicKey: Uint8Array, openssh: string, status: string}|null} */
|
|
232
|
+
#hostKey = null;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Called with the host's verified key before any credential is sent.
|
|
236
|
+
* Return `false` (or throw) to refuse the host. See `connect({ onHostKey })`.
|
|
237
|
+
* @type {((hostKey: object) => boolean|void|Promise<boolean|void>)|null}
|
|
238
|
+
*/
|
|
239
|
+
onHostKey = null;
|
|
240
|
+
|
|
204
241
|
/** @type {{ wt: () => import('./transport.mjs').WshTransport, ws: () => import('./transport.mjs').WshTransport }} */
|
|
205
242
|
#transportFactories;
|
|
206
243
|
|
|
@@ -409,6 +446,18 @@ export class WshClient {
|
|
|
409
446
|
return this.#serverFeatures.includes(name);
|
|
410
447
|
}
|
|
411
448
|
|
|
449
|
+
/**
|
|
450
|
+
* The host's Ed25519 identity, once the server has proven it holds the
|
|
451
|
+
* key for this connection (`null` when the server advertised none, or
|
|
452
|
+
* before `connect()` got that far). `{ fingerprint, publicKey, openssh,
|
|
453
|
+
* status }` where `status` is `'pinned'` (matched `expectHostKey`),
|
|
454
|
+
* `'known'` / `'unknown'` (per `knownHosts`), or `'unpinned'` (no policy
|
|
455
|
+
* asked for one -- the key is surfaced but nothing was checked against it).
|
|
456
|
+
*/
|
|
457
|
+
get hostKey() {
|
|
458
|
+
return this.#hostKey;
|
|
459
|
+
}
|
|
460
|
+
|
|
412
461
|
// ── Connection ──────────────────────────────────────────────────────
|
|
413
462
|
|
|
414
463
|
/**
|
|
@@ -429,9 +478,21 @@ export class WshClient {
|
|
|
429
478
|
* fine), or base64. Ignored by the WebSocket rung, which has no
|
|
430
479
|
* equivalent mechanism.
|
|
431
480
|
* @param {number} [opts.timeout] - Auth handshake timeout in ms
|
|
481
|
+
* @param {string|Uint8Array} [opts.expectHostKey] - Pin: the host's hex
|
|
482
|
+
* SHA-256 fingerprint, raw 32-byte Ed25519 key, or `ssh-ed25519 AAAA...`
|
|
483
|
+
* line. Refuses (`HostKeyError`) on mismatch OR when the host presents
|
|
484
|
+
* no verifiable key -- before any credential is sent.
|
|
485
|
+
* @param {import('./known-hosts.mjs').WshKnownHosts} [opts.knownHosts] - TOFU
|
|
486
|
+
* store. A changed key is always refused; an unseen host is refused
|
|
487
|
+
* unless `trustOnFirstUse` (or an `onHostKey` that does not return
|
|
488
|
+
* `false`) accepts it, in which case it is pinned.
|
|
489
|
+
* @param {boolean} [opts.trustOnFirstUse] - Pin an unseen host's key.
|
|
490
|
+
* @param {string} [opts.hostLabel] - Key under which `knownHosts` records
|
|
491
|
+
* this host (default: the URL's `host:port`).
|
|
492
|
+
* @param {Function} [opts.onHostKey] - Per-connect `onHostKey`.
|
|
432
493
|
* @returns {Promise<string>} The server-assigned session ID
|
|
433
494
|
*/
|
|
434
|
-
async connect(url, { username, keyPair, password, transport: transportHint, webTransport, timeout = DEFAULT_AUTH_TIMEOUT } = {}) {
|
|
495
|
+
async connect(url, { username, keyPair, password, transport: transportHint, webTransport, timeout = DEFAULT_AUTH_TIMEOUT, ...hostKeyOpts } = {}) {
|
|
435
496
|
if (this.#state !== STATE_DISCONNECTED && this.#state !== STATE_CLOSED) {
|
|
436
497
|
throw new Error(`Client already ${this.#state}`);
|
|
437
498
|
}
|
|
@@ -448,6 +509,7 @@ export class WshClient {
|
|
|
448
509
|
this.#waiters.clear();
|
|
449
510
|
this.#sessionId = null;
|
|
450
511
|
this.#authToken = null;
|
|
512
|
+
this.#hostKey = null;
|
|
451
513
|
|
|
452
514
|
try {
|
|
453
515
|
// ── Select and connect transport ──────────────────────────────
|
|
@@ -457,9 +519,11 @@ export class WshClient {
|
|
|
457
519
|
|
|
458
520
|
// ── Auth handshake ────────────────────────────────────────────
|
|
459
521
|
const authMethod = keyPair ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
|
|
522
|
+
const hostKeyNonce = newHostKeyNonce();
|
|
460
523
|
await transport.sendControl(
|
|
461
|
-
hello({ username, authMethod })
|
|
524
|
+
hello({ username, authMethod, features: [HOST_KEY_NONCE_PREFIX + hostKeyNonce] })
|
|
462
525
|
);
|
|
526
|
+
const hostCtx = { ...hostKeyOpts, url, username, nonce: hostKeyNonce };
|
|
463
527
|
|
|
464
528
|
// Wait for SERVER_HELLO (which may include a session ID directly) or CHALLENGE.
|
|
465
529
|
const firstResponse = await this.#waitForMessage(
|
|
@@ -478,14 +542,18 @@ export class WshClient {
|
|
|
478
542
|
// Server may proceed directly to auth if it accepted the hello.
|
|
479
543
|
tempSessionId = firstResponse.session_id;
|
|
480
544
|
this.#serverFeatures = firstResponse.features || [];
|
|
545
|
+
// If pubkey auth, we still need a challenge. Its waiter is registered
|
|
546
|
+
// BEFORE the host key check awaits anything: the server sends the
|
|
547
|
+
// Challenge right behind ServerHello, and a message nobody is waiting
|
|
548
|
+
// for yet is dropped.
|
|
549
|
+
const challengeWait = authMethod === AUTH_METHOD.PUBKEY
|
|
550
|
+
? this.#waitForMessage([MSG.CHALLENGE, MSG.AUTH_OK], timeout, 'Auth handshake timed out waiting for challenge')
|
|
551
|
+
: null;
|
|
552
|
+
challengeWait?.catch(() => {});
|
|
553
|
+
await this.#verifyHostKey(firstResponse, hostCtx);
|
|
481
554
|
|
|
482
|
-
// If pubkey auth, we still need a challenge.
|
|
483
555
|
if (authMethod === AUTH_METHOD.PUBKEY) {
|
|
484
|
-
const challengeMsg = await
|
|
485
|
-
[MSG.CHALLENGE, MSG.AUTH_OK],
|
|
486
|
-
timeout,
|
|
487
|
-
'Auth handshake timed out waiting for challenge'
|
|
488
|
-
);
|
|
556
|
+
const challengeMsg = await challengeWait;
|
|
489
557
|
|
|
490
558
|
if (challengeMsg.type === MSG.AUTH_OK) {
|
|
491
559
|
// Server accepted without challenge (e.g. trusted key).
|
|
@@ -525,6 +593,7 @@ export class WshClient {
|
|
|
525
593
|
}
|
|
526
594
|
} else if (firstResponse.type === MSG.CHALLENGE) {
|
|
527
595
|
// Some servers skip SERVER_HELLO and go straight to CHALLENGE.
|
|
596
|
+
await this.#verifyHostKey(null, hostCtx);
|
|
528
597
|
if (authMethod !== AUTH_METHOD.PUBKEY || !keyPair) {
|
|
529
598
|
throw new Error('Server sent CHALLENGE but no key pair was provided');
|
|
530
599
|
}
|
|
@@ -858,13 +927,13 @@ export class WshClient {
|
|
|
858
927
|
* @param {boolean} [opts.primer=true] - See `openSession({ primer })`
|
|
859
928
|
* @returns {Promise<{stdout: Uint8Array, exitCode: number}>}
|
|
860
929
|
*/
|
|
861
|
-
static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT, primer = true } = {}) {
|
|
930
|
+
static async exec(url, command, { username, keyPair, password, timeout = DEFAULT_EXEC_TIMEOUT, primer = true, ...hostKeyOpts } = {}) {
|
|
862
931
|
const client = new WshClient();
|
|
863
932
|
const chunks = [];
|
|
864
933
|
let exitCode = -1;
|
|
865
934
|
|
|
866
935
|
try {
|
|
867
|
-
await client.connect(url, { username, keyPair, password });
|
|
936
|
+
await client.connect(url, { username, keyPair, password, ...hostKeyOpts });
|
|
868
937
|
|
|
869
938
|
const session = await client.openSession({ type: 'exec', command, primer });
|
|
870
939
|
|
|
@@ -935,9 +1004,10 @@ export class WshClient {
|
|
|
935
1004
|
supportsReplay,
|
|
936
1005
|
supportsEcho,
|
|
937
1006
|
supportsTermSync,
|
|
1007
|
+
...hostKeyOpts
|
|
938
1008
|
} = {}) {
|
|
939
1009
|
// Authenticate normally first.
|
|
940
|
-
const sessionId = await this.connect(url, { username, keyPair, password, webTransport });
|
|
1010
|
+
const sessionId = await this.connect(url, { username, keyPair, password, webTransport, ...hostKeyOpts });
|
|
941
1011
|
|
|
942
1012
|
// Build capabilities list from expose options.
|
|
943
1013
|
const capabilities = [];
|
|
@@ -1719,15 +1789,57 @@ export class WshClient {
|
|
|
1719
1789
|
|
|
1720
1790
|
/**
|
|
1721
1791
|
* Perform a file operation on the remote host.
|
|
1792
|
+
*
|
|
1793
|
+
* `write` and `rename` carry data the wsh-v1 `FileOp` frame has no field
|
|
1794
|
+
* for, so both send the payload as a `FileChunk` on the same channel id
|
|
1795
|
+
* (no frame or field outside the spec), and the host must advertise
|
|
1796
|
+
* `file-write` / `file-rename` in ServerHello; against a host that does
|
|
1797
|
+
* not, they throw instead of sending a request the host would misread:
|
|
1798
|
+
* - `write`: `FileOp{ op: 'write', path, offset?, length }`, then the bytes
|
|
1799
|
+
* as `FileChunk` frames (the last with `is_final`).
|
|
1800
|
+
* - `rename`: `FileOp{ op: 'rename', path: <old> }`, then one final
|
|
1801
|
+
* `FileChunk` whose data is the UTF-8 destination path.
|
|
1802
|
+
* Either way the single `FileResult` arrives after the final chunk.
|
|
1803
|
+
*
|
|
1722
1804
|
* @param {string} op - Operation: 'stat', 'list', 'read', 'write', 'mkdir', 'remove', 'rename'
|
|
1723
1805
|
* @param {string} path - File path
|
|
1724
|
-
* @param {object} [opts] - Optional: offset, length for read; data for write; newPath for rename
|
|
1806
|
+
* @param {object} [opts] - Optional: offset, length for read; `data` (+ `offset`) for write; `newPath` for rename
|
|
1725
1807
|
* @param {number} [timeout=10000]
|
|
1726
1808
|
* @returns {Promise<object>} FileResult response
|
|
1727
1809
|
*/
|
|
1728
1810
|
async fileOperation(op, path, opts = {}, timeout = DEFAULT_OPEN_TIMEOUT) {
|
|
1729
1811
|
this.#assertAuthenticated('fileOperation');
|
|
1730
1812
|
const channelId = this._nextChannelId();
|
|
1813
|
+
|
|
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
|
+
|
|
1731
1843
|
await this.#transport.sendControl(
|
|
1732
1844
|
fileOpMsg({ channelId, op, path, offset: opts.offset, length: opts.length })
|
|
1733
1845
|
);
|
|
@@ -1745,13 +1857,13 @@ export class WshClient {
|
|
|
1745
1857
|
/** Read a remote file. */
|
|
1746
1858
|
async fileRead(path, offset, length, timeout) { return this.fileOperation('read', path, { offset, length }, timeout); }
|
|
1747
1859
|
/** Write to a remote file. */
|
|
1748
|
-
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); }
|
|
1749
1861
|
/** Create a remote directory. */
|
|
1750
1862
|
async fileMkdir(path, timeout) { return this.fileOperation('mkdir', path, {}, timeout); }
|
|
1751
1863
|
/** Remove a remote file or directory. */
|
|
1752
1864
|
async fileRemove(path, timeout) { return this.fileOperation('remove', path, {}, timeout); }
|
|
1753
1865
|
/** Rename a remote file or directory. */
|
|
1754
|
-
async fileRename(oldPath, newPath, timeout) { return this.fileOperation('rename', oldPath, {}, timeout); }
|
|
1866
|
+
async fileRename(oldPath, newPath, timeout) { return this.fileOperation('rename', oldPath, { newPath }, timeout); }
|
|
1755
1867
|
|
|
1756
1868
|
// ── Authorized-key management (wsh #59) ───────────────────────────
|
|
1757
1869
|
|
|
@@ -1955,7 +2067,95 @@ export class WshClient {
|
|
|
1955
2067
|
// Proceed with auth using the same logic.
|
|
1956
2068
|
// We can't call this.connect() directly because it would create a new
|
|
1957
2069
|
// transport, so we duplicate the auth portion.
|
|
1958
|
-
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
|
+
}
|
|
1959
2159
|
}
|
|
1960
2160
|
|
|
1961
2161
|
// ── Internal: auth handshake ────────────────────────────────────────
|
|
@@ -1972,13 +2172,16 @@ export class WshClient {
|
|
|
1972
2172
|
* @returns {Promise<string>} Session ID
|
|
1973
2173
|
* @private
|
|
1974
2174
|
*/
|
|
1975
|
-
async #performAuth({ username, keyPair, password, timeout = DEFAULT_AUTH_TIMEOUT } = {}) {
|
|
2175
|
+
async #performAuth({ username, keyPair, password, timeout = DEFAULT_AUTH_TIMEOUT, url, ...hostKeyOpts } = {}) {
|
|
1976
2176
|
if (!username) throw new Error('username is required');
|
|
1977
2177
|
if (!keyPair && !password) throw new Error('Either keyPair or password is required');
|
|
1978
2178
|
|
|
2179
|
+
this.#hostKey = null;
|
|
1979
2180
|
try {
|
|
1980
2181
|
const authMethod = keyPair ? AUTH_METHOD.PUBKEY : AUTH_METHOD.PASSWORD;
|
|
1981
|
-
|
|
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 };
|
|
1982
2185
|
|
|
1983
2186
|
const firstResponse = await this.#waitForMessage(
|
|
1984
2187
|
[MSG.SERVER_HELLO, MSG.CHALLENGE, MSG.AUTH_FAIL],
|
|
@@ -1995,13 +2198,15 @@ export class WshClient {
|
|
|
1995
2198
|
if (firstResponse.type === MSG.SERVER_HELLO) {
|
|
1996
2199
|
tempSessionId = firstResponse.session_id;
|
|
1997
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);
|
|
1998
2207
|
|
|
1999
2208
|
if (authMethod === AUTH_METHOD.PUBKEY) {
|
|
2000
|
-
const challengeMsg = await
|
|
2001
|
-
[MSG.CHALLENGE, MSG.AUTH_OK],
|
|
2002
|
-
timeout,
|
|
2003
|
-
'Timed out waiting for challenge'
|
|
2004
|
-
);
|
|
2209
|
+
const challengeMsg = await challengeWait;
|
|
2005
2210
|
|
|
2006
2211
|
if (challengeMsg.type === MSG.AUTH_OK) {
|
|
2007
2212
|
this.#sessionId = challengeMsg.session_id || tempSessionId;
|
|
@@ -2025,6 +2230,7 @@ export class WshClient {
|
|
|
2025
2230
|
}
|
|
2026
2231
|
} else if (firstResponse.type === MSG.CHALLENGE) {
|
|
2027
2232
|
if (!keyPair) throw new Error('Server sent CHALLENGE but no key pair provided');
|
|
2233
|
+
await this.#verifyHostKey(null, hostCtx);
|
|
2028
2234
|
|
|
2029
2235
|
// Challenge carries session_id directly — see connect()'s
|
|
2030
2236
|
// CHALLENGE branch for why. Keep tempSessionId as a fallback for
|