@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/index.d.ts
CHANGED
|
@@ -238,6 +238,13 @@ export const PROTOCOL_VERSION: 'wsh-v1';
|
|
|
238
238
|
*/
|
|
239
239
|
export const MCP_CALL_ID_FEATURE: 'mcp-call-id';
|
|
240
240
|
|
|
241
|
+
/**
|
|
242
|
+
* ServerHello feature: the host discovers a client-opened exec data stream
|
|
243
|
+
* without help (the transport announces it), so the client writes no
|
|
244
|
+
* one-byte primer against it.
|
|
245
|
+
*/
|
|
246
|
+
export const STREAM_ANNOUNCE_FEATURE: 'stream-announce';
|
|
247
|
+
|
|
241
248
|
/** A wsh protocol control message (all messages have a numeric `type`). */
|
|
242
249
|
export interface WshMessage {
|
|
243
250
|
type: number;
|
|
@@ -895,15 +902,19 @@ export function exportPrivateKeyPKCS8(privateKey: CryptoKey): Promise<Uint8Array
|
|
|
895
902
|
export function importPrivateKeyPKCS8(pkcs8: Uint8Array, extractable?: boolean): Promise<CryptoKey>;
|
|
896
903
|
|
|
897
904
|
/**
|
|
898
|
-
* Sign data with an Ed25519 private key.
|
|
905
|
+
* Sign data with an Ed25519 private key. Positional order mirrors WebCrypto
|
|
906
|
+
* (`sign(privateKey, message)`) and is the canonical form across wsh, raijin
|
|
907
|
+
* and browsermesh.
|
|
899
908
|
* @returns 64-byte signature
|
|
900
909
|
*/
|
|
901
|
-
export function sign(privateKey: CryptoKey,
|
|
910
|
+
export function sign(privateKey: CryptoKey, message: Uint8Array): Promise<Uint8Array>;
|
|
902
911
|
|
|
903
912
|
/**
|
|
904
|
-
* Verify an Ed25519 signature.
|
|
913
|
+
* Verify an Ed25519 signature. Positional order mirrors WebCrypto
|
|
914
|
+
* (`verify(publicKey, signature, message)`) and is the canonical form across
|
|
915
|
+
* wsh, raijin and browsermesh.
|
|
905
916
|
*/
|
|
906
|
-
export function verify(publicKey: CryptoKey, signature: Uint8Array,
|
|
917
|
+
export function verify(publicKey: CryptoKey, signature: Uint8Array, message: Uint8Array): Promise<boolean>;
|
|
907
918
|
|
|
908
919
|
/** Options binding extra context into the auth transcript. */
|
|
909
920
|
export interface WshTranscriptOptions {
|
|
@@ -1538,8 +1549,48 @@ export class WshVirtualSessionBackend {
|
|
|
1538
1549
|
// client.mjs -- WshClient
|
|
1539
1550
|
// ============================================================================
|
|
1540
1551
|
|
|
1552
|
+
/** The host identity a server proved it holds (see `WshClient.hostKey`). */
|
|
1553
|
+
export interface WshHostKey {
|
|
1554
|
+
/** Hex SHA-256 of the raw key (same as `fingerprint()` / `ServerHello.host_fingerprint`). */
|
|
1555
|
+
fingerprint: string;
|
|
1556
|
+
/** Raw 32-byte Ed25519 host public key. */
|
|
1557
|
+
publicKey: Uint8Array;
|
|
1558
|
+
/** `ssh-ed25519 AAAA...` form, for display or `expectHostKey`. */
|
|
1559
|
+
openssh: string;
|
|
1560
|
+
/** `pinned` matched `expectHostKey`; `known`/`unknown` per `knownHosts`; `unpinned` = surfaced, nothing checked. */
|
|
1561
|
+
status: 'pinned' | 'known' | 'unknown' | 'unpinned';
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/** Why a host key was refused. */
|
|
1565
|
+
export type WshHostKeyErrorCode =
|
|
1566
|
+
| 'HOST_KEY_MISSING' | 'HOST_KEY_INVALID' | 'HOST_KEY_MISMATCH' | 'HOST_KEY_UNKNOWN' | 'HOST_KEY_REJECTED';
|
|
1567
|
+
|
|
1568
|
+
/** Thrown by `connect()` when the host key fails the caller's policy. No credential has been sent. */
|
|
1569
|
+
export class HostKeyError extends Error {
|
|
1570
|
+
constructor(code: WshHostKeyErrorCode, message: string, details?: { expected?: string; actual?: string });
|
|
1571
|
+
readonly code: WshHostKeyErrorCode;
|
|
1572
|
+
/** MISMATCH: the pinned / expected fingerprint. */
|
|
1573
|
+
readonly expected?: string;
|
|
1574
|
+
/** MISMATCH / UNKNOWN: the fingerprint the host presented. */
|
|
1575
|
+
readonly actual?: string;
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/** Host-key policy shared by `connect()`, `exec()` and `connectReverse()`. */
|
|
1579
|
+
export interface WshHostKeyOptions {
|
|
1580
|
+
/** Pin: hex fingerprint (optionally `sha256:`-prefixed), raw 32-byte key, or `ssh-ed25519 AAAA...` line. Refuses on mismatch or when the host presents no key. */
|
|
1581
|
+
expectHostKey?: string | Uint8Array;
|
|
1582
|
+
/** TOFU store. A changed key is always refused; an unseen host is refused unless `trustOnFirstUse` / `onHostKey` accepts it. */
|
|
1583
|
+
knownHosts?: WshKnownHosts;
|
|
1584
|
+
/** Pin an unseen host's key into `knownHosts`. */
|
|
1585
|
+
trustOnFirstUse?: boolean;
|
|
1586
|
+
/** Key `knownHosts` records this host under (default: the URL's `host:port`). */
|
|
1587
|
+
hostLabel?: string;
|
|
1588
|
+
/** Sees the verified key before any credential is sent; return `false` (or throw) to refuse. */
|
|
1589
|
+
onHostKey?: (hostKey: WshHostKey) => boolean | void | Promise<boolean | void>;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1541
1592
|
/** Options for WshClient.connect(). */
|
|
1542
|
-
export interface WshConnectOptions {
|
|
1593
|
+
export interface WshConnectOptions extends WshHostKeyOptions {
|
|
1543
1594
|
username: string;
|
|
1544
1595
|
keyPair?: CryptoKeyPair;
|
|
1545
1596
|
password?: string;
|
|
@@ -1561,6 +1612,13 @@ export interface WshOpenSessionOptions {
|
|
|
1561
1612
|
rows?: number;
|
|
1562
1613
|
env?: Record<string, string>;
|
|
1563
1614
|
timeout?: number;
|
|
1615
|
+
/**
|
|
1616
|
+
* Stream-mode `exec` only (default `true`): write the one-byte "primer"
|
|
1617
|
+
* that older hosts need to discover the client-opened data stream. Skipped
|
|
1618
|
+
* automatically against a host advertising `stream-announce`; `false`
|
|
1619
|
+
* never sends it.
|
|
1620
|
+
*/
|
|
1621
|
+
primer?: boolean;
|
|
1564
1622
|
}
|
|
1565
1623
|
|
|
1566
1624
|
/** Options for WshClient.attachSession(). */
|
|
@@ -1591,6 +1649,11 @@ export interface WshConnectReverseOptions {
|
|
|
1591
1649
|
password?: string;
|
|
1592
1650
|
/** Forwarded to the underlying `connect()` -- see `WshConnectOptions`. */
|
|
1593
1651
|
webTransport?: WshWebTransportOptions;
|
|
1652
|
+
expectHostKey?: WshHostKeyOptions['expectHostKey'];
|
|
1653
|
+
knownHosts?: WshKnownHosts;
|
|
1654
|
+
trustOnFirstUse?: boolean;
|
|
1655
|
+
hostLabel?: string;
|
|
1656
|
+
onHostKey?: WshHostKeyOptions['onHostKey'];
|
|
1594
1657
|
expose?: {
|
|
1595
1658
|
shell?: boolean;
|
|
1596
1659
|
exec?: boolean;
|
|
@@ -1606,11 +1669,13 @@ export interface WshConnectReverseOptions {
|
|
|
1606
1669
|
}
|
|
1607
1670
|
|
|
1608
1671
|
/** Options for WshClient.exec(). */
|
|
1609
|
-
export interface WshExecOptions {
|
|
1672
|
+
export interface WshExecOptions extends WshHostKeyOptions {
|
|
1610
1673
|
username: string;
|
|
1611
1674
|
keyPair?: CryptoKeyPair;
|
|
1612
1675
|
password?: string;
|
|
1613
1676
|
timeout?: number;
|
|
1677
|
+
/** See {@link WshOpenSessionOptions.primer}. */
|
|
1678
|
+
primer?: boolean;
|
|
1614
1679
|
}
|
|
1615
1680
|
|
|
1616
1681
|
/** Result from WshClient.exec(). */
|
|
@@ -1756,6 +1821,15 @@ export class WshClient {
|
|
|
1756
1821
|
*/
|
|
1757
1822
|
hasFeature(name: string): boolean;
|
|
1758
1823
|
|
|
1824
|
+
/**
|
|
1825
|
+
* The host's Ed25519 identity once it has proven it holds the key for this
|
|
1826
|
+
* connection; `null` if the server advertised none (e.g. the Rust server).
|
|
1827
|
+
*/
|
|
1828
|
+
readonly hostKey: WshHostKey | null;
|
|
1829
|
+
|
|
1830
|
+
/** Default `onHostKey` for `connect()` (a per-call option wins). */
|
|
1831
|
+
onHostKey: ((hostKey: WshHostKey) => boolean | void | Promise<boolean | void>) | null;
|
|
1832
|
+
|
|
1759
1833
|
/**
|
|
1760
1834
|
* Connect to a wsh server, authenticate, and return the session ID.
|
|
1761
1835
|
*/
|
|
@@ -1814,6 +1888,20 @@ export class WshClient {
|
|
|
1814
1888
|
*/
|
|
1815
1889
|
fileList(path: string, timeout?: number): Promise<WshFileOperationResult>;
|
|
1816
1890
|
|
|
1891
|
+
/**
|
|
1892
|
+
* Write bytes to a remote file. `offset` omitted replaces the file; given,
|
|
1893
|
+
* writes in place at that offset. Needs the host feature `file-write`
|
|
1894
|
+
* (`@johnhenry/wsh/server` with `fs` has it); throws against a host without
|
|
1895
|
+
* it. Failure is reported as `success: false` in the result.
|
|
1896
|
+
*/
|
|
1897
|
+
fileWrite(path: string, data: Uint8Array | string, offset?: number, timeout?: number): Promise<WshFileOperationResult>;
|
|
1898
|
+
|
|
1899
|
+
/**
|
|
1900
|
+
* Rename/move a remote file or directory. Needs the host feature
|
|
1901
|
+
* `file-rename`; throws against a host without it. Refuses to overwrite.
|
|
1902
|
+
*/
|
|
1903
|
+
fileRename(oldPath: string, newPath: string, timeout?: number): Promise<WshFileOperationResult>;
|
|
1904
|
+
|
|
1817
1905
|
/**
|
|
1818
1906
|
* Remove a remote file or directory (`FileOp` op `"remove"`). Refused
|
|
1819
1907
|
* today by every `wsh-server` release ("not yet implemented" -- see
|
package/src/index.mjs
CHANGED
|
@@ -73,13 +73,14 @@ export {
|
|
|
73
73
|
// Session + Client
|
|
74
74
|
export { WshSession } from './session.mjs';
|
|
75
75
|
export { WshVirtualSessionBackend, normalizeSessionData } from './virtual-session.mjs';
|
|
76
|
-
export { WshClient, MCP_CALL_ID_FEATURE } from './client.mjs';
|
|
76
|
+
export { WshClient, MCP_CALL_ID_FEATURE, STREAM_ANNOUNCE_FEATURE } from './client.mjs';
|
|
77
77
|
|
|
78
78
|
// Key storage
|
|
79
79
|
export { WshKeyStore } from './keystore.mjs';
|
|
80
80
|
|
|
81
81
|
// Host identity (TOFU) -- wsh #59
|
|
82
82
|
export { WshKnownHosts } from './known-hosts.mjs';
|
|
83
|
+
export { HostKeyError } from './host-key.mjs';
|
|
83
84
|
|
|
84
85
|
// File transfer
|
|
85
86
|
export { WshFileTransfer } from './file-transfer.mjs';
|
package/src/qmux-connection.mjs
CHANGED
|
@@ -134,6 +134,27 @@ class QMuxStream {
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Make this locally-initiated stream visible to the peer without sending
|
|
139
|
+
* any payload. A QMux/QUIC stream is created lazily on the wire: opening
|
|
140
|
+
* one only allocates an id locally, and the peer first learns it exists
|
|
141
|
+
* from the first STREAM frame it sees. A zero-length STREAM frame (no FIN)
|
|
142
|
+
* is legal QUIC and is exactly that first frame, so the peer's
|
|
143
|
+
* `onStreamOpen` fires now instead of whenever (if ever) the first real
|
|
144
|
+
* byte is written -- which for an exec session, with no stdin, is never.
|
|
145
|
+
* No-op once anything has been sent on the stream.
|
|
146
|
+
*/
|
|
147
|
+
announce() {
|
|
148
|
+
if (this.#sendState !== SEND_STATE.READY) return;
|
|
149
|
+
this.#sendState = SEND_STATE.SEND;
|
|
150
|
+
this.#conn._sendFrame(encodeStream({
|
|
151
|
+
streamId: this.id,
|
|
152
|
+
offset: this.#sendOffset,
|
|
153
|
+
data: new Uint8Array(0),
|
|
154
|
+
fin: false,
|
|
155
|
+
}), 0);
|
|
156
|
+
}
|
|
157
|
+
|
|
137
158
|
/** Half-close the send side: send FIN. No more write() calls after this. */
|
|
138
159
|
async close() {
|
|
139
160
|
if (this.#sendState === SEND_STATE.RESET_SENT || this.#sendState === SEND_STATE.DATA_SENT) return;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side authentication for `@johnhenry/wsh/server`: an allowlist of
|
|
3
|
+
* Ed25519 public keys (SSH `authorized_keys` style) and/or a custom callback.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
parseSSHPublicKey, extractRawFromSSHWire, fingerprint,
|
|
8
|
+
} from '../auth.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Parse an `authorized_keys`-style blob (one `ssh-ed25519 AAAA... [comment]`
|
|
12
|
+
* per line; blank lines and `#` comments ignored) into raw 32-byte keys.
|
|
13
|
+
* Malformed lines are skipped rather than failing the whole list.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} text
|
|
16
|
+
* @returns {Uint8Array[]}
|
|
17
|
+
*/
|
|
18
|
+
export function parseAuthorizedKeys(text) {
|
|
19
|
+
const keys = [];
|
|
20
|
+
for (const raw of String(text ?? '').split('\n')) {
|
|
21
|
+
const line = raw.trim();
|
|
22
|
+
if (!line || line.startsWith('#')) continue;
|
|
23
|
+
try {
|
|
24
|
+
const parsed = parseSSHPublicKey(line);
|
|
25
|
+
keys.push(extractRawFromSSHWire(parsed.data));
|
|
26
|
+
} catch {
|
|
27
|
+
// skip malformed line
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return keys;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalize the `auth` option into one `authorize()` function.
|
|
35
|
+
*
|
|
36
|
+
* `auth` is an `authorize` function itself, or an object with
|
|
37
|
+
* `authorizedKeys` (a string of `ssh-ed25519` lines, a raw 32-byte
|
|
38
|
+
* `Uint8Array`, or an array of either) and/or `authorize`. A key must be on
|
|
39
|
+
* the list AND pass `authorize` (when both are given). With no `auth` at all
|
|
40
|
+
* every connection is refused -- a server never defaults to open.
|
|
41
|
+
*
|
|
42
|
+
* @param {undefined | Function | {
|
|
43
|
+
* authorizedKeys?: string | Uint8Array | Array<string | Uint8Array>,
|
|
44
|
+
* authorize?: (who: {username: string, fingerprint: string, publicKey: Uint8Array}) => boolean | Promise<boolean>,
|
|
45
|
+
* }} auth
|
|
46
|
+
* @returns {Promise<(who: {username: string, fingerprint: string, publicKey: Uint8Array}) => Promise<boolean>>}
|
|
47
|
+
*/
|
|
48
|
+
export async function buildAuthorizer(auth) {
|
|
49
|
+
if (typeof auth === 'function') return async (who) => (await auth(who)) === true;
|
|
50
|
+
if (!auth) return async () => false;
|
|
51
|
+
|
|
52
|
+
/** @type {Set<string> | null} */
|
|
53
|
+
let allowed = null;
|
|
54
|
+
if (auth.authorizedKeys !== undefined) {
|
|
55
|
+
allowed = new Set();
|
|
56
|
+
const items = Array.isArray(auth.authorizedKeys) ? auth.authorizedKeys : [auth.authorizedKeys];
|
|
57
|
+
for (const item of items) {
|
|
58
|
+
const raws = typeof item === 'string' ? parseAuthorizedKeys(item) : [item];
|
|
59
|
+
for (const raw of raws) {
|
|
60
|
+
if (!(raw instanceof Uint8Array) || raw.byteLength !== 32) {
|
|
61
|
+
throw new TypeError('createWshServer: auth.authorizedKeys entries must be ssh-ed25519 lines or raw 32-byte Ed25519 keys');
|
|
62
|
+
}
|
|
63
|
+
allowed.add(await fingerprint(raw));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const custom = typeof auth.authorize === 'function' ? auth.authorize : null;
|
|
68
|
+
if (!allowed && !custom) return async () => false;
|
|
69
|
+
|
|
70
|
+
return async (who) => {
|
|
71
|
+
if (allowed && !allowed.has(who.fingerprint)) return false;
|
|
72
|
+
if (custom) return (await custom(who)) === true;
|
|
73
|
+
return true;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether `auth` allows public-key login at all (an allowlist and/or an
|
|
79
|
+
* `authorize` callback, or the function shorthand) and/or password login.
|
|
80
|
+
*
|
|
81
|
+
* @param {any} auth
|
|
82
|
+
* @returns {{ pubkey: boolean, password: ((user: string, pass: string) => boolean | Promise<boolean>) | null }}
|
|
83
|
+
*/
|
|
84
|
+
export function authMethods(auth) {
|
|
85
|
+
if (typeof auth === 'function') return { pubkey: true, password: null };
|
|
86
|
+
if (!auth) return { pubkey: false, password: null };
|
|
87
|
+
if (auth.password !== undefined && typeof auth.password !== 'function') {
|
|
88
|
+
throw new TypeError('createWshServer: auth.password must be a function (user, pass) => boolean | Promise<boolean>');
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
pubkey: auth.authorizedKeys !== undefined || typeof auth.authorize === 'function',
|
|
92
|
+
password: auth.password ?? null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Failure throttle for password logins, keyed (by default) on the peer's
|
|
98
|
+
* address. After `maxFailures` failures inside `windowMs` the key is locked
|
|
99
|
+
* for `lockoutMs`, during which the password callback is not even called.
|
|
100
|
+
* A success clears the key. In-memory and per-process, by design.
|
|
101
|
+
*/
|
|
102
|
+
export class FailureLimiter {
|
|
103
|
+
#entries = new Map();
|
|
104
|
+
#now;
|
|
105
|
+
constructor({ maxFailures = 5, windowMs = 60_000, lockoutMs = 60_000, now = Date.now } = {}) {
|
|
106
|
+
this.maxFailures = maxFailures;
|
|
107
|
+
this.windowMs = windowMs;
|
|
108
|
+
this.lockoutMs = lockoutMs;
|
|
109
|
+
this.#now = now;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @returns {number} ms the key is still locked out (0 = free to try) */
|
|
113
|
+
lockedFor(key) {
|
|
114
|
+
const e = this.#entries.get(key);
|
|
115
|
+
if (!e) return 0;
|
|
116
|
+
const t = this.#now();
|
|
117
|
+
if (e.lockedUntil > t) return e.lockedUntil - t;
|
|
118
|
+
if (t - e.first > this.windowMs) this.#entries.delete(key);
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fail(key) {
|
|
123
|
+
const t = this.#now();
|
|
124
|
+
let e = this.#entries.get(key);
|
|
125
|
+
if (!e || t - e.first > this.windowMs) { e = { count: 0, first: t, lockedUntil: 0 }; this.#entries.set(key, e); }
|
|
126
|
+
e.count++;
|
|
127
|
+
if (e.count >= this.maxFailures) e.lockedUntil = t + this.lockoutMs;
|
|
128
|
+
if (this.#entries.size > 10_000) this.#entries.delete(this.#entries.keys().next().value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
succeed(key) { this.#entries.delete(key); }
|
|
132
|
+
}
|