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