@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/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, data: Uint8Array): Promise<Uint8Array>;
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, data: Uint8Array): Promise<boolean>;
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';
@@ -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
+ }