@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.
@@ -20,6 +20,36 @@ export interface WshServerAuthOptions {
20
20
  authorizedKeys?: string | Uint8Array | Array<string | Uint8Array>;
21
21
  /** Extra policy, run after the signature verifies (and after the allowlist, when given). */
22
22
  authorize?: (who: WshServerPrincipal) => boolean | Promise<boolean>;
23
+ /**
24
+ * Enable password login: return `true` to admit. Sent in the clear inside the
25
+ * connection -- serve over `wss://` / a trusted link. Use a constant-time
26
+ * compare against hashes, never `===` on plaintext. Failures are throttled
27
+ * (see `rateLimit`); the callback is not consulted while a caller is locked out.
28
+ */
29
+ password?: (username: string, password: string) => boolean | Promise<boolean>;
30
+ /** Password-failure throttle. */
31
+ rateLimit?: {
32
+ /** Failures inside `windowMs` before lockout (default 5). */
33
+ maxFailures?: number;
34
+ /** Failure counting window (default 60000). */
35
+ windowMs?: number;
36
+ /** Lockout length once tripped (default 60000). */
37
+ lockoutMs?: number;
38
+ /** Pause before answering a failed attempt (default 250). */
39
+ failureDelayMs?: number;
40
+ /** Throttle bucket; default the peer address. Use this behind a proxy (e.g. read `x-forwarded-for` from `headers`). */
41
+ key?: (info: { address: string | undefined; headers: Record<string, string | string[] | undefined>; username: string }) => string;
42
+ };
43
+ }
44
+
45
+ /** The server's own identity, as advertised to clients. */
46
+ export interface WshServerHostKey {
47
+ /** Hex SHA-256 of the raw key. */
48
+ fingerprint: string;
49
+ /** Raw 32-byte Ed25519 key. */
50
+ publicKey: Uint8Array;
51
+ /** `ssh-ed25519 AAAA...` */
52
+ openssh: string;
23
53
  }
24
54
 
25
55
  /** What a custom `exec` runner receives for one session. */
@@ -100,6 +130,12 @@ export interface WshServerOptions {
100
130
  exec?: true | WshServerExecOptions | WshExecRunner;
101
131
  /** Enable `pty` sessions by injecting a pty implementation. Off by default. */
102
132
  pty?: WshServerPtyOptions;
133
+ /**
134
+ * Advertise an Ed25519 host identity (with proof of possession) so clients can pin it.
135
+ * `{ file }` persists a PKCS#8 PEM (created 0600 on first start) -- the normal choice;
136
+ * `true` is a fresh key every start; or pass a `CryptoKeyPair`. Off by default.
137
+ */
138
+ hostKey?: true | { file: string } | CryptoKeyPair;
103
139
  /** Enable file ops and uploads/downloads under one directory. Off by default. */
104
140
  fs?: WshServerFsOptions;
105
141
  /** How long exec output waits for a client that has not yet opened its data stream (default 3000). */
@@ -119,6 +155,8 @@ export interface WshServer {
119
155
  close(): Promise<void>;
120
156
  /** The bound address, or `null` when not listening. */
121
157
  address(): WshServerAddress | null;
158
+ /** The advertised host identity; `null` before `listen()` resolves or without a `hostKey`. */
159
+ hostKey(): WshServerHostKey | null;
122
160
  }
123
161
 
124
162
  export function createWshServer(options?: WshServerOptions): WshServer;
@@ -8,7 +8,8 @@
8
8
  * refused.
9
9
  */
10
10
 
11
- import { buildAuthorizer, parseAuthorizedKeys } from './auth.mjs';
11
+ import { buildAuthorizer, parseAuthorizedKeys, authMethods, FailureLimiter } from './auth.mjs';
12
+ import { loadHostKey } from './host-key.mjs';
12
13
  import { spawnRunner } from './exec.mjs';
13
14
  import { normalizePty } from './pty.mjs';
14
15
  import { createFileAccess } from './fs.mjs';
@@ -25,12 +26,16 @@ export { parseAuthorizedKeys, STREAM_ANNOUNCE };
25
26
  * `{ cwd, env, shell, timeoutMs, clientEnv }`, or a custom `run(command, io)` function.
26
27
  * @param {{ spawn: Function, shell?: string, cwd?: string, env?: object }} [options.pty]
27
28
  * @param {{ root: string, readOnly?: boolean, maxFileBytes?: number }} [options.fs]
29
+ * @param {true | { file: string } | CryptoKeyPair} [options.hostKey] - The server's own Ed25519
30
+ * identity, advertised (with proof of possession) so clients can pin it. See `src/host-key.mjs`.
31
+ * @param {object} [options.auth.rateLimit] - Password-failure throttle: `{ maxFailures=5, windowMs=60000,
32
+ * lockoutMs=60000, failureDelayMs=250, key(info) }` (`key` defaults to the peer address).
28
33
  * @param {number} [options.bindTimeoutMs=3000]
29
34
  * @param {(line: string) => void} [options.onLog]
30
35
  * @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
31
36
  */
32
37
  export function createWshServer({
33
- host = '127.0.0.1', port = 0, auth, exec, pty, fs, bindTimeoutMs = 3000, onLog = () => {},
38
+ host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, bindTimeoutMs = 3000, onLog = () => {},
34
39
  } = {}) {
35
40
  let wss = null;
36
41
  let bound = null;
@@ -45,6 +50,14 @@ export function createWshServer({
45
50
  }
46
51
  const ptyConfig = pty ? normalizePty(pty) : null;
47
52
  const files = fs ? createFileAccess(fs) : null;
53
+ const methods = authMethods(auth);
54
+ const rl = auth?.rateLimit ?? {};
55
+ const rateLimit = {
56
+ limiter: new FailureLimiter(rl),
57
+ key: typeof rl.key === 'function' ? rl.key : ({ address }) => String(address ?? 'unknown'),
58
+ failureDelayMs: rl.failureDelayMs ?? 250,
59
+ };
60
+ let host_ = null;
48
61
 
49
62
  return {
50
63
  async listen() {
@@ -56,13 +69,18 @@ export function createWshServer({
56
69
  throw new Error('@johnhenry/wsh/server needs the "ws" package: npm install ws', { cause: err });
57
70
  }
58
71
  const authorize = await buildAuthorizer(auth);
72
+ host_ = hostKey ? await loadHostKey(hostKey) : null;
59
73
  const attach = createConnectionFactory({
60
74
  authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
75
+ methods, hostKey: host_, rateLimit,
61
76
  });
62
77
 
63
78
  wss = new WebSocketServer({ host, port });
64
- wss.on('connection', (ws) => {
65
- const conn = attach({ send: (b) => { if (ws.readyState === 1) ws.send(b); } });
79
+ wss.on('connection', (ws, req) => {
80
+ const conn = attach({
81
+ send: (b) => { if (ws.readyState === 1) ws.send(b); },
82
+ remote: { address: req?.socket?.remoteAddress, headers: req?.headers ?? {} },
83
+ });
66
84
  ws.on('message', (d) => {
67
85
  const buf = Array.isArray(d) ? Buffer.concat(d) : Buffer.isBuffer(d) ? d : Buffer.from(d);
68
86
  conn.receive(new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength));
@@ -91,5 +109,10 @@ export function createWshServer({
91
109
  address() {
92
110
  return bound;
93
111
  },
112
+
113
+ /** The advertised host identity (`null` until `listen()` resolves, or with no `hostKey`). */
114
+ hostKey() {
115
+ return host_ ? { fingerprint: host_.fingerprint, publicKey: host_.publicKey, openssh: host_.openssh } : null;
116
+ },
94
117
  };
95
118
  }