@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.
- package/README.md +123 -15
- package/package.json +1 -1
- package/src/client.mjs +228 -22
- package/src/host-key.mjs +97 -0
- package/src/index.d.ts +70 -2
- package/src/index.mjs +1 -0
- package/src/server/auth.mjs +57 -0
- package/src/server/connection.mjs +119 -7
- package/src/server/fs.mjs +46 -6
- package/src/server/host-key.mjs +66 -0
- package/src/server/index.d.ts +38 -0
- package/src/server/index.mjs +27 -4
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
|
+
}
|
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';
|
package/src/server/auth.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
156
|
+
state.method = method;
|
|
139
157
|
const features = [STREAM_ANNOUNCE];
|
|
140
|
-
if (cfg.files) features.push('file-transfer');
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
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
|
+
}
|