@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.
@@ -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
+ }
package/src/index.d.ts CHANGED
@@ -1549,8 +1549,48 @@ export class WshVirtualSessionBackend {
1549
1549
  // client.mjs -- WshClient
1550
1550
  // ============================================================================
1551
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
+
1552
1592
  /** Options for WshClient.connect(). */
1553
- export interface WshConnectOptions {
1593
+ export interface WshConnectOptions extends WshHostKeyOptions {
1554
1594
  username: string;
1555
1595
  keyPair?: CryptoKeyPair;
1556
1596
  password?: string;
@@ -1609,6 +1649,11 @@ export interface WshConnectReverseOptions {
1609
1649
  password?: string;
1610
1650
  /** Forwarded to the underlying `connect()` -- see `WshConnectOptions`. */
1611
1651
  webTransport?: WshWebTransportOptions;
1652
+ expectHostKey?: WshHostKeyOptions['expectHostKey'];
1653
+ knownHosts?: WshKnownHosts;
1654
+ trustOnFirstUse?: boolean;
1655
+ hostLabel?: string;
1656
+ onHostKey?: WshHostKeyOptions['onHostKey'];
1612
1657
  expose?: {
1613
1658
  shell?: boolean;
1614
1659
  exec?: boolean;
@@ -1624,7 +1669,7 @@ export interface WshConnectReverseOptions {
1624
1669
  }
1625
1670
 
1626
1671
  /** Options for WshClient.exec(). */
1627
- export interface WshExecOptions {
1672
+ export interface WshExecOptions extends WshHostKeyOptions {
1628
1673
  username: string;
1629
1674
  keyPair?: CryptoKeyPair;
1630
1675
  password?: string;
@@ -1776,6 +1821,15 @@ export class WshClient {
1776
1821
  */
1777
1822
  hasFeature(name: string): boolean;
1778
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
+
1779
1833
  /**
1780
1834
  * Connect to a wsh server, authenticate, and return the session ID.
1781
1835
  */
@@ -1834,6 +1888,20 @@ export class WshClient {
1834
1888
  */
1835
1889
  fileList(path: string, timeout?: number): Promise<WshFileOperationResult>;
1836
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
+
1837
1905
  /**
1838
1906
  * Remove a remote file or directory (`FileOp` op `"remove"`). Refused
1839
1907
  * today by every `wsh-server` release ("not yet implemented" -- see
package/src/index.mjs CHANGED
@@ -80,6 +80,7 @@ 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';
@@ -73,3 +73,60 @@ export async function buildAuthorizer(auth) {
73
73
  return true;
74
74
  };
75
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
+ }
@@ -24,8 +24,12 @@ import {
24
24
  generateNonce, verifyChallenge, fingerprint, importPublicKeyRaw,
25
25
  } from '../auth.mjs';
26
26
  import { FILE_CHUNK_BYTES } from './fs.mjs';
27
+ import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
27
28
 
28
29
  export const STREAM_ANNOUNCE = 'stream-announce';
30
+ const MAX_PENDING_WRITES = 8;
31
+ const MAX_RENAME_PATH_BYTES = 4096;
32
+ const WRITE_IDLE_MS = 30_000;
29
33
 
30
34
  const MAX_CHANNELS = 64;
31
35
  const USERNAME_RE = /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/;
@@ -41,13 +45,16 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
41
45
  * @param {object} cfg.execOptions
42
46
  * @param {object | null} cfg.pty - normalized (see pty.mjs)
43
47
  * @param {object | null} cfg.files - from createFileAccess
48
+ * @param {{ pubkey: boolean, password: Function | null }} cfg.methods - which auth methods are on
49
+ * @param {object | null} cfg.hostKey - from loadHostKey
50
+ * @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
44
51
  * @param {number} cfg.bindTimeoutMs - how long exec output waits for the data stream
45
52
  * @param {(line: string) => void} cfg.log
46
53
  */
47
54
  export function createConnectionFactory(cfg) {
48
55
  let counter = 0;
49
56
 
50
- return function attach({ send: sendBytes }) {
57
+ return function attach({ send: sendBytes, remote = {} }) {
51
58
  const cid = ++counter;
52
59
  const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
53
60
  const state = {
@@ -56,6 +63,8 @@ export function createConnectionFactory(cfg) {
56
63
  };
57
64
  /** @type {Map<number, any>} */
58
65
  const channels = new Map();
66
+ /** FileOp `write`/`rename`s awaiting their FileChunk frames, by client-chosen channel id. */
67
+ const fileWrites = new Map();
59
68
  /** Channel ids awaiting their client-opened data stream, in OpenOk order. */
60
69
  const pendingStreams = [];
61
70
  const decoder = new FrameDecoder();
@@ -78,6 +87,8 @@ export function createConnectionFactory(cfg) {
78
87
  state.closed = true;
79
88
  for (const ch of channels.values()) ch.kill?.();
80
89
  channels.clear();
90
+ for (const w of fileWrites.values()) clearTimeout(w.timer);
91
+ fileWrites.clear();
81
92
  try { qmux.destroy(new Error(why)); } catch { /* already gone */ }
82
93
  log(`closed (${why})`);
83
94
  }
@@ -118,7 +129,7 @@ export function createConnectionFactory(cfg) {
118
129
  case MSG.SIGNAL: channels.get(m.channel_id)?.signal?.(String(m.signal ?? '')); return;
119
130
  case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
120
131
  case MSG.FILE_OP: return handleFileOp(m);
121
- case MSG.FILE_CHUNK: return channels.get(m.channel_id)?.chunk?.(m);
132
+ case MSG.FILE_CHUNK: return (fileWrites.has(m.channel_id) ? handleWriteChunk(m) : channels.get(m.channel_id)?.chunk?.(m));
122
133
  default: log(`ignored message type 0x${m.type.toString(16)}`);
123
134
  }
124
135
  }
@@ -133,15 +144,37 @@ export function createConnectionFactory(cfg) {
133
144
  if (m.type === MSG.HELLO) {
134
145
  const user = String(m.username ?? '');
135
146
  if (!USERNAME_RE.test(user)) return refuse(`bad username ${JSON.stringify(user)}`);
136
- if (m.auth_method && m.auth_method !== 'pubkey') return refuse('this host only accepts Ed25519 pubkey auth');
147
+ const method = m.auth_method || 'pubkey';
148
+ if (method === 'password') {
149
+ if (!cfg.methods.password) return refuse('password auth is not enabled on this host');
150
+ } else if (method === 'pubkey') {
151
+ if (!cfg.methods.pubkey) return refuse('pubkey auth is not enabled on this host');
152
+ } else {
153
+ return refuse(`unsupported auth method ${JSON.stringify(method)}`);
154
+ }
137
155
  state.username = user;
138
- state.nonce = generateNonce();
156
+ state.method = method;
139
157
  const features = [STREAM_ANNOUNCE];
140
- if (cfg.files) features.push('file-transfer');
141
- await send(serverHello({ sessionId: state.sessionId, features }));
142
- await send(challenge({ nonce: state.nonce, sessionId: state.sessionId }));
158
+ if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
159
+ let hostFingerprint;
160
+ if (cfg.hostKey) {
161
+ hostFingerprint = cfg.hostKey.fingerprint;
162
+ const clientNonce = findClientNonce(m.features);
163
+ if (clientNonce) {
164
+ const sig = await cfg.hostKey.sign(hostKeyProofMessage({ sessionId: state.sessionId, clientNonce, username: user }));
165
+ features.push(HOST_KEY_PREFIX + toHex(cfg.hostKey.publicKey), HOST_KEY_SIG_PREFIX + toHex(sig));
166
+ }
167
+ }
168
+ await send(serverHello({ sessionId: state.sessionId, features, hostFingerprint }));
169
+ if (method === 'pubkey') {
170
+ state.nonce = generateNonce();
171
+ await send(challenge({ nonce: state.nonce, sessionId: state.sessionId }));
172
+ } else {
173
+ state.awaitingPassword = true;
174
+ }
143
175
  return;
144
176
  }
177
+ if (m.type === MSG.AUTH && state.awaitingPassword) return handlePasswordAuth(m);
145
178
  if (m.type === MSG.AUTH) {
146
179
  if (!state.nonce) return;
147
180
  if (m.method !== 'pubkey' || !(m.public_key instanceof Uint8Array) || !(m.signature instanceof Uint8Array)) {
@@ -172,6 +205,29 @@ export function createConnectionFactory(cfg) {
172
205
  }
173
206
  }
174
207
 
208
+ async function handlePasswordAuth(m) {
209
+ state.awaitingPassword = false; // one guess per connection
210
+ if (m.method !== 'password' || typeof m.password !== 'string') return refuse('expected a password AUTH');
211
+ const rl = cfg.rateLimit;
212
+ const key = rl.key({ address: remote.address, headers: remote.headers, username: state.username });
213
+ const locked = rl.limiter.lockedFor(key);
214
+ if (locked > 0) {
215
+ // Not even evaluated: a locked-out caller learns nothing and costs nothing.
216
+ return refuse(`too many failed attempts; try again in ${Math.ceil(locked / 1000)}s`);
217
+ }
218
+ let ok = false;
219
+ try { ok = (await cfg.methods.password(state.username, m.password)) === true; } catch (e) { log(`password check threw: ${e.message}`); }
220
+ if (!ok) {
221
+ rl.limiter.fail(key);
222
+ if (rl.failureDelayMs > 0) await new Promise((r) => setTimeout(r, rl.failureDelayMs));
223
+ return refuse('authentication failed');
224
+ }
225
+ rl.limiter.succeed(key);
226
+ state.authed = true;
227
+ await send(authOk({ sessionId: state.sessionId, token: randomBytes(16), ttl: 3600 }));
228
+ log(`authenticated ${state.username} (password)`);
229
+ }
230
+
175
231
  // ── Open ──────────────────────────────────────────────────────────
176
232
 
177
233
  function handleOpen(m) {
@@ -363,6 +419,7 @@ export function createConnectionFactory(cfg) {
363
419
  async function handleFileOp(m) {
364
420
  const channelId = m.channel_id;
365
421
  if (!cfg.files) return send(fileResult({ channelId, success: false, errorMessage: 'file access is not enabled on this server' }));
422
+ if (m.op === 'write' || m.op === 'rename') return startPayloadOp(m);
366
423
  try {
367
424
  const r = await cfg.files.operate(m.op, m.path, { offset: m.offset, length: m.length });
368
425
  return send(fileResult({ channelId, success: true, metadata: r.metadata ?? {}, entries: r.entries ?? [] }));
@@ -373,6 +430,61 @@ export function createConnectionFactory(cfg) {
373
430
  }
374
431
  }
375
432
 
433
+ // write / rename = FileOp then FileChunk frame(s) on the same channel id
434
+ // (rename's payload is the UTF-8 destination path); one FileResult once
435
+ // the last chunk lands (see WshClient.fileOperation).
436
+ async function startPayloadOp(m) {
437
+ const channelId = m.channel_id;
438
+ const failWrite = (errorMessage) => send(fileResult({ channelId, success: false, errorMessage }));
439
+ const rename = m.op === 'rename';
440
+ const length = Number(m.length);
441
+ if (!Number.isSafeInteger(length) || length < 0) return failWrite(`${m.op} needs a length`);
442
+ const limit = rename ? MAX_RENAME_PATH_BYTES : cfg.files.maxFileBytes;
443
+ if (length > limit) return failWrite(`${m.op} payload of ${length} bytes exceeds the ${limit} byte limit`);
444
+ if (channels.has(channelId) || fileWrites.has(channelId)) return failWrite('channel id already in use');
445
+ if (fileWrites.size >= MAX_PENDING_WRITES) return failWrite('too many writes in flight');
446
+ const w = { rename, path: m.path, offset: m.offset, length, buf: new Uint8Array(length), next: 0, timer: null };
447
+ const arm = () => {
448
+ clearTimeout(w.timer);
449
+ w.timer = setTimeout(() => {
450
+ if (fileWrites.delete(channelId)) failWrite('write timed out waiting for data');
451
+ }, WRITE_IDLE_MS);
452
+ w.timer.unref?.();
453
+ };
454
+ arm();
455
+ w.arm = arm;
456
+ fileWrites.set(channelId, w);
457
+ }
458
+
459
+ async function handleWriteChunk(m) {
460
+ const channelId = m.channel_id;
461
+ const w = fileWrites.get(channelId);
462
+ const abort = (errorMessage) => {
463
+ clearTimeout(w.timer);
464
+ fileWrites.delete(channelId);
465
+ return send(fileResult({ channelId, success: false, errorMessage }));
466
+ };
467
+ const data = m.data instanceof Uint8Array ? m.data : new Uint8Array(0);
468
+ if (m.offset !== w.next) return abort('chunks must arrive in order');
469
+ if (w.next + data.byteLength > w.length) return abort('chunk past the declared length');
470
+ w.buf.set(data, w.next);
471
+ w.next += data.byteLength;
472
+ if (!m.is_final) { w.arm(); return; }
473
+ clearTimeout(w.timer);
474
+ fileWrites.delete(channelId);
475
+ if (w.next !== w.length) return send(fileResult({ channelId, success: false, errorMessage: 'payload ended short of the declared length' }));
476
+ try {
477
+ const r = w.rename
478
+ ? await cfg.files.operate('rename', w.path, { newPath: new TextDecoder('utf-8', { fatal: true }).decode(w.buf) })
479
+ : await cfg.files.operate('write', w.path, { offset: w.offset, data: w.buf });
480
+ return send(fileResult({ channelId, success: true, metadata: r.metadata ?? {} }));
481
+ } catch (e) {
482
+ const message = e.wsh ? e.message : (e.code === 'ENOENT' ? 'no such file or directory' : `${w.rename ? 'rename' : 'write'} failed`);
483
+ if (!e.wsh && e.code !== 'ENOENT') log(`file write ${w.path}: ${e.message}`);
484
+ return send(fileResult({ channelId, success: false, errorMessage: message }));
485
+ }
486
+ }
487
+
376
488
  return {
377
489
  receive(bytes) { if (!state.closed) qmux.receiveBytes(bytes); },
378
490
  close() { shutdown('transport closed'); },
package/src/server/fs.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * File access for `@johnhenry/wsh/server`: FileOp requests (list, stat, read,
3
- * mkdir, remove) and `upload:`/`download:` file channels, confined to one
3
+ * write, rename, mkdir, remove) and `upload:`/`download:` file channels, confined to one
4
4
  * root directory.
5
5
  *
6
6
  * Every path a client sends is resolved against `root`; a path that would
@@ -8,8 +8,9 @@
8
8
  */
9
9
 
10
10
  import {
11
- readdir, lstat, stat, readFile, writeFile, mkdir, rmdir, unlink, readlink, realpath, open,
11
+ readdir, lstat, stat, readFile, writeFile, mkdir, rmdir, unlink, readlink, realpath, open, rename,
12
12
  } from 'node:fs/promises';
13
+ import { constants as fsc } from 'node:fs';
13
14
  import path from 'node:path';
14
15
 
15
16
  export const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
@@ -71,7 +72,7 @@ export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAUL
71
72
  maxFileBytes,
72
73
 
73
74
  /** @returns {Promise<{ metadata?: object, entries?: object[] }>} */
74
- async operate(op, clientPath, { offset, length } = {}) {
75
+ async operate(op, clientPath, { offset, length, newPath, data } = {}) {
75
76
  switch (op) {
76
77
  case 'list': {
77
78
  const dir = await resolveInside(clientPath || '/');
@@ -111,8 +112,28 @@ export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAUL
111
112
  await ((await lstat(full)).isDirectory() ? rmdir(full) : unlink(full));
112
113
  return { metadata: { removed: clientPath } };
113
114
  }
115
+ case 'write': {
116
+ if (!(data instanceof Uint8Array)) throw fail('write needs data');
117
+ await this.writeAt(clientPath, data, offset);
118
+ return { metadata: { written: data.byteLength } };
119
+ }
120
+ case 'rename': {
121
+ assertWritable();
122
+ if (typeof newPath !== 'string' || !newPath) throw fail('rename needs a destination path');
123
+ const from = await resolveInside(clientPath);
124
+ const to = await resolveInside(newPath);
125
+ if (from === base || to === base) throw fail('refusing to rename the file root');
126
+ if (from === to) return { metadata: { renamed: clientPath, to: newPath } };
127
+ if (to.startsWith(from + path.sep)) throw fail('cannot move a directory into itself');
128
+ await lstat(from); // ENOENT -> "no such file or directory"
129
+ let exists = true;
130
+ try { await lstat(to); } catch (e) { if (e.code === 'ENOENT') exists = false; else throw e; }
131
+ if (exists) throw fail('destination already exists');
132
+ await rename(from, to);
133
+ return { metadata: { renamed: clientPath, to: newPath } };
134
+ }
114
135
  default:
115
- throw fail(`"${op}" is not offered by this host (list, stat, read, mkdir, remove; use upload/download to write)`);
136
+ throw fail(`"${op}" is not offered by this host (list, stat, read, write, rename, mkdir, remove)`);
116
137
  }
117
138
  },
118
139
 
@@ -125,10 +146,29 @@ export function createFileAccess({ root, readOnly = false, maxFileBytes = DEFAUL
125
146
  },
126
147
 
127
148
  async writeWhole(clientPath, data) {
149
+ return this.writeAt(clientPath, data, undefined, { mkdirs: true });
150
+ },
151
+
152
+ /**
153
+ * Write bytes into a regular file. `offset` omitted: replace the whole
154
+ * file (create/truncate). `offset` given: write in place at that byte
155
+ * offset without truncating (creates the file if missing).
156
+ */
157
+ async writeAt(clientPath, data, offset, { mkdirs = false } = {}) {
128
158
  assertWritable();
129
159
  const full = await resolveInside(clientPath);
130
- await mkdir(path.dirname(full), { recursive: true });
131
- await writeFile(full, data);
160
+ if (full === base) throw fail('not a regular file');
161
+ const at = offset === undefined || offset === null ? undefined : Number(offset);
162
+ if (at !== undefined && (!Number.isSafeInteger(at) || at < 0)) throw fail('illegal offset');
163
+ if ((at ?? 0) + data.byteLength > maxFileBytes) throw fail(`write would exceed the ${maxFileBytes} byte limit`);
164
+ if (mkdirs) await mkdir(path.dirname(full), { recursive: true });
165
+ // A FIFO or device would block or misbehave on open: regular files only.
166
+ try {
167
+ if (!(await stat(full)).isFile()) throw fail('not a regular file');
168
+ } catch (e) { if (e.wsh) throw e; if (e.code !== 'ENOENT') throw e; }
169
+ if (at === undefined) { await writeFile(full, data); return; }
170
+ const fh = await open(full, fsc.O_RDWR | fsc.O_CREAT, 0o644);
171
+ try { await fh.write(data, 0, data.byteLength, at); } finally { await fh.close(); }
132
172
  },
133
173
  };
134
174
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The server's own Ed25519 identity (see ../host-key.mjs for the wire shape).
3
+ *
4
+ * `hostKey` option forms:
5
+ * - `true` ephemeral key, new every start (TOFU clients will see "changed" after a restart -- tests/demos only)
6
+ * - `{ file: '/path/host_key' }` PKCS#8 PEM; created (mode 0600) on first start, reused after -- the normal choice
7
+ * - a `CryptoKeyPair` your own key, e.g. from `generateKeyPair()`
8
+ */
9
+
10
+ import { generateKeyPairSync, createPrivateKey, createPublicKey, sign as nodeSign } from 'node:crypto';
11
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { exportPublicKeyRaw, importPublicKeyRaw, exportPublicKeySSH, sign, fingerprint } from '../auth.mjs';
14
+
15
+ const PEM_OPTS = { type: 'pkcs8', format: 'pem' };
16
+
17
+ function fromNodePrivateKey(priv) {
18
+ const x = createPublicKey(priv).export({ format: 'jwk' }).x;
19
+ return Buffer.from(x, 'base64url');
20
+ }
21
+
22
+ /**
23
+ * @returns {Promise<{ publicKey: Uint8Array, fingerprint: string, openssh: string, sign(data: Uint8Array): Promise<Uint8Array> }>}
24
+ */
25
+ export async function loadHostKey(option) {
26
+ let raw;
27
+ let signFn;
28
+
29
+ if (option === true) {
30
+ const priv = generateKeyPairSync('ed25519').privateKey;
31
+ raw = new Uint8Array(fromNodePrivateKey(priv));
32
+ signFn = async (data) => new Uint8Array(nodeSign(null, data, priv));
33
+ } else if (option && typeof option === 'object' && typeof option.file === 'string') {
34
+ let pem;
35
+ try {
36
+ pem = await readFile(option.file, 'utf8');
37
+ } catch (err) {
38
+ if (err.code !== 'ENOENT') throw err;
39
+ await mkdir(path.dirname(path.resolve(option.file)), { recursive: true });
40
+ const fresh = generateKeyPairSync('ed25519').privateKey.export(PEM_OPTS);
41
+ try {
42
+ await writeFile(option.file, fresh, { mode: 0o600, flag: 'wx' });
43
+ pem = fresh;
44
+ } catch (e) {
45
+ if (e.code !== 'EEXIST') throw e;
46
+ pem = await readFile(option.file, 'utf8'); // another start won the race
47
+ }
48
+ }
49
+ const priv = createPrivateKey(pem);
50
+ if (priv.asymmetricKeyType !== 'ed25519') throw new TypeError(`createWshServer: hostKey.file ${option.file} is not an Ed25519 key`);
51
+ raw = new Uint8Array(fromNodePrivateKey(priv));
52
+ signFn = async (data) => new Uint8Array(nodeSign(null, data, priv));
53
+ } else if (option && option.privateKey && option.publicKey) {
54
+ raw = await exportPublicKeyRaw(option.publicKey);
55
+ signFn = (data) => sign(option.privateKey, data);
56
+ } else {
57
+ throw new TypeError('createWshServer: hostKey must be true, { file }, or a CryptoKeyPair');
58
+ }
59
+
60
+ return {
61
+ publicKey: raw,
62
+ fingerprint: await fingerprint(raw),
63
+ openssh: await exportPublicKeySSH(await importPublicKeyRaw(raw)),
64
+ sign: signFn,
65
+ };
66
+ }