@johnhenry/wsh 0.27.1 → 0.29.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 +89 -36
- package/package.json +1 -1
- package/src/client.mjs +51 -47
- package/src/e2e-exchange.mjs +227 -0
- package/src/index.d.ts +50 -3
- package/src/index.mjs +4 -1
- package/src/messages.gen.mjs +13 -7
- package/src/server/connection.mjs +94 -4
- package/src/server/index.d.ts +46 -0
- package/src/server/index.mjs +33 -7
- package/src/server/relay.mjs +113 -25
- package/src/server/reverse-host.mjs +93 -28
package/src/index.d.ts
CHANGED
|
@@ -616,6 +616,14 @@ export function reverseReject(opts?: {
|
|
|
616
616
|
export function relayForward(opts?: {
|
|
617
617
|
fromFingerprint?: string;
|
|
618
618
|
inner?: Uint8Array;
|
|
619
|
+
/** Peer -> relay: the operator this is for, when the peer serves several (wsh #89). */
|
|
620
|
+
toFingerprint?: string;
|
|
621
|
+
}): WshMessage;
|
|
622
|
+
|
|
623
|
+
/** Relay -> peer: one operator of a multi-operator peer left (wsh #89). */
|
|
624
|
+
export function reverseClose(opts?: {
|
|
625
|
+
targetFingerprint?: string;
|
|
626
|
+
reason?: string;
|
|
619
627
|
}): WshMessage;
|
|
620
628
|
|
|
621
629
|
/** Message types a relay may forward inside a RelayForward wrapper. */
|
|
@@ -851,6 +859,8 @@ export function keyExchange(opts?: {
|
|
|
851
859
|
sessionId?: string;
|
|
852
860
|
kemPublicKey?: Uint8Array;
|
|
853
861
|
kemCiphertext?: Uint8Array;
|
|
862
|
+
/** Round 1 only: the sender's Ed25519 signature over the exchange (see the spec's KeyExchange). */
|
|
863
|
+
signature?: Uint8Array;
|
|
854
864
|
}): WshMessage;
|
|
855
865
|
|
|
856
866
|
export function encryptedFrame(opts?: {
|
|
@@ -1909,6 +1919,27 @@ export interface WshPeerInfo {
|
|
|
1909
1919
|
verified: boolean;
|
|
1910
1920
|
}
|
|
1911
1921
|
|
|
1922
|
+
/** Host feature: it answers `KeyExchange` and seals/opens `EncryptedFrame`s (a reverse host with `e2e` on). */
|
|
1923
|
+
export const E2E_FEATURE: 'e2e';
|
|
1924
|
+
/** Host feature: it signs its `KeyExchange` reply, so `initiateE2E(..., { verifyPeer })` can authenticate it. */
|
|
1925
|
+
export const E2E_SIGN_FEATURE: 'e2e-sign';
|
|
1926
|
+
|
|
1927
|
+
/** The bytes a responder signs in `KeyExchange.signature`. */
|
|
1928
|
+
export function keyExchangeTranscript(t: {
|
|
1929
|
+
sessionId: string;
|
|
1930
|
+
/** The initiator's `algorithm` string. */
|
|
1931
|
+
algorithm: string;
|
|
1932
|
+
initiatorKey: Uint8Array;
|
|
1933
|
+
responderKey: Uint8Array;
|
|
1934
|
+
}): Uint8Array;
|
|
1935
|
+
|
|
1936
|
+
/** Verify a responder's `KeyExchange.signature` against its long-term Ed25519 public key. */
|
|
1937
|
+
export function verifyKeyExchangeSignature(
|
|
1938
|
+
peerIdentity: Uint8Array | CryptoKey,
|
|
1939
|
+
signature: Uint8Array | undefined,
|
|
1940
|
+
transcript: Parameters<typeof keyExchangeTranscript>[0],
|
|
1941
|
+
): Promise<boolean>;
|
|
1942
|
+
|
|
1912
1943
|
/** Result from WshClient.initiateE2E(). */
|
|
1913
1944
|
export interface WshE2EResult {
|
|
1914
1945
|
/** Non-extractable AES-256-GCM key derived from the exchange. */
|
|
@@ -1921,6 +1952,8 @@ export interface WshE2EResult {
|
|
|
1921
1952
|
* requested but the peer didn't support it (automatic fallback).
|
|
1922
1953
|
*/
|
|
1923
1954
|
hybrid: boolean;
|
|
1955
|
+
/** True when `verifyPeer` was given and the peer's signature over the exchange verified. */
|
|
1956
|
+
peerAuthenticated: boolean;
|
|
1924
1957
|
}
|
|
1925
1958
|
|
|
1926
1959
|
/**
|
|
@@ -2005,7 +2038,13 @@ export class WshClient {
|
|
|
2005
2038
|
* In reverse mode, the relay bridge forwards messages from the CLI peer
|
|
2006
2039
|
* to this browser client.
|
|
2007
2040
|
*/
|
|
2008
|
-
onRelayMessage: ((msg: WshMessage) => void) | null;
|
|
2041
|
+
onRelayMessage: ((msg: WshMessage, fromFingerprint?: string) => void) | null;
|
|
2042
|
+
|
|
2043
|
+
/**
|
|
2044
|
+
* Called on a peer that serves several operators when the relay reports that one left (`ReverseClose`,
|
|
2045
|
+
* wsh #89): end that operator's bridge and clean up what belongs to it.
|
|
2046
|
+
*/
|
|
2047
|
+
onReverseClose: ((msg: WshMessage) => void) | null;
|
|
2009
2048
|
|
|
2010
2049
|
/**
|
|
2011
2050
|
* Called when a gateway-subsystem control message arrives (opcodes 0x70-0x7f).
|
|
@@ -2169,7 +2208,7 @@ export class WshClient {
|
|
|
2169
2208
|
/**
|
|
2170
2209
|
* Send a control message over the authenticated relay connection.
|
|
2171
2210
|
*/
|
|
2172
|
-
sendRelayControl(msg: WshMessage): Promise<void>;
|
|
2211
|
+
sendRelayControl(msg: WshMessage, opts?: { to?: string }): Promise<void>;
|
|
2173
2212
|
|
|
2174
2213
|
/**
|
|
2175
2214
|
* Send a raw control message on the authenticated connection.
|
|
@@ -2202,7 +2241,15 @@ export class WshClient {
|
|
|
2202
2241
|
* fallback to classical if the peer doesn't support it). Experimental
|
|
2203
2242
|
* -- the derived key is not yet wired to any actual encryption.
|
|
2204
2243
|
*/
|
|
2205
|
-
initiateE2E(
|
|
2244
|
+
initiateE2E(
|
|
2245
|
+
sessionId: string,
|
|
2246
|
+
algorithm?: 'X25519' | 'X25519+ML-KEM-768',
|
|
2247
|
+
timeout?: number,
|
|
2248
|
+
opts?: {
|
|
2249
|
+
/** The peer's long-term Ed25519 public key; the exchange is refused (`E2E_PEER_UNAUTHENTICATED`) unless it signed its half. */
|
|
2250
|
+
verifyPeer?: Uint8Array | CryptoKey;
|
|
2251
|
+
},
|
|
2252
|
+
): Promise<WshE2EResult>;
|
|
2206
2253
|
|
|
2207
2254
|
/**
|
|
2208
2255
|
* Mark a peer fingerprint as an accepted reverse-connect bridge partner.
|
package/src/index.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export {
|
|
|
17
17
|
presence, controlChanged, metrics,
|
|
18
18
|
mcpDiscover, mcpTools, mcpCall, mcpResult,
|
|
19
19
|
reverseRegister, reverseList, reversePeers, reverseConnect, reverseAccept, reverseReject,
|
|
20
|
-
relayForward, RELAY_FORWARDABLE, isRelayForwardable,
|
|
20
|
+
relayForward, reverseClose, RELAY_FORWARDABLE, isRelayForwardable,
|
|
21
21
|
openTcp, openUdp, resolveDns, gatewayOk, gatewayFail, gatewayClose,
|
|
22
22
|
inboundOpen, inboundAccept, inboundReject, dnsResult,
|
|
23
23
|
listenRequest, listenOk, listenFail, listenClose, gatewayData,
|
|
@@ -76,6 +76,9 @@ export { WshSession } from './session.mjs';
|
|
|
76
76
|
export { WshVirtualSessionBackend, normalizeSessionData } from './virtual-session.mjs';
|
|
77
77
|
export { WshClient, MCP_CALL_ID_FEATURE, STREAM_ANNOUNCE_FEATURE } from './client.mjs';
|
|
78
78
|
|
|
79
|
+
// End-to-end key exchange (responder side, feature names, signed transcript) -- wsh #90
|
|
80
|
+
export { E2E_FEATURE, E2E_SIGN_FEATURE, keyExchangeTranscript, verifyKeyExchangeSignature } from './e2e-exchange.mjs';
|
|
81
|
+
|
|
79
82
|
// Key storage
|
|
80
83
|
export { WshKeyStore } from './keystore.mjs';
|
|
81
84
|
|
package/src/messages.gen.mjs
CHANGED
|
@@ -63,6 +63,7 @@ export const MSG = Object.freeze({
|
|
|
63
63
|
REVERSE_ACCEPT: 0x54,
|
|
64
64
|
REVERSE_REJECT: 0x55,
|
|
65
65
|
RELAY_FORWARD: 0x56,
|
|
66
|
+
REVERSE_CLOSE: 0x57,
|
|
66
67
|
|
|
67
68
|
// Session
|
|
68
69
|
SESSION_LIST: 0x5f,
|
|
@@ -510,12 +511,16 @@ export function reverseReject({ targetFingerprint, username, reason } = {}) {
|
|
|
510
511
|
};
|
|
511
512
|
}
|
|
512
513
|
|
|
513
|
-
export function relayForward({ fromFingerprint, inner } = {}) {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
514
|
+
export function relayForward({ fromFingerprint, inner, toFingerprint } = {}) {
|
|
515
|
+
const msg = { type: MSG.RELAY_FORWARD, from_fingerprint: fromFingerprint, inner };
|
|
516
|
+
if (toFingerprint !== undefined) msg.to_fingerprint = toFingerprint;
|
|
517
|
+
return msg;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function reverseClose({ targetFingerprint, reason } = {}) {
|
|
521
|
+
const msg = { type: MSG.REVERSE_CLOSE, target_fingerprint: targetFingerprint };
|
|
522
|
+
if (reason !== undefined) msg.reason = reason;
|
|
523
|
+
return msg;
|
|
519
524
|
}
|
|
520
525
|
|
|
521
526
|
export function sessionList({ sessions } = {}) {
|
|
@@ -762,11 +767,12 @@ export function copilotDetach({ sessionId, reason } = {}) {
|
|
|
762
767
|
return msg;
|
|
763
768
|
}
|
|
764
769
|
|
|
765
|
-
export function keyExchange({ algorithm, publicKey, sessionId, kemPublicKey, kemCiphertext } = {}) {
|
|
770
|
+
export function keyExchange({ algorithm, publicKey, sessionId, kemPublicKey, kemCiphertext, signature } = {}) {
|
|
766
771
|
const msg = { type: MSG.KEY_EXCHANGE, algorithm, session_id: sessionId };
|
|
767
772
|
if (publicKey !== undefined) msg.public_key = publicKey;
|
|
768
773
|
if (kemPublicKey !== undefined) msg.kem_public_key = kemPublicKey;
|
|
769
774
|
if (kemCiphertext !== undefined) msg.kem_ciphertext = kemCiphertext;
|
|
775
|
+
if (signature !== undefined) msg.signature = signature;
|
|
770
776
|
return msg;
|
|
771
777
|
}
|
|
772
778
|
|
|
@@ -18,7 +18,7 @@ import { QMuxConnection } from '../qmux-connection.mjs';
|
|
|
18
18
|
import { FrameDecoder, frameEncode } from '../cbor.mjs';
|
|
19
19
|
import {
|
|
20
20
|
MSG, serverHello, challenge, authOk, authFail, openOk, openFail, sessionData,
|
|
21
|
-
exit as exitMsg, close as closeMsg, pong, fileResult, fileChunk, mcpTools, mcpResult,
|
|
21
|
+
exit as exitMsg, close as closeMsg, encryptedFrame as encryptedFrameMsg, pong, fileResult, fileChunk, mcpTools, mcpResult,
|
|
22
22
|
presence as presenceMsg, error as errorMsg, sessionList, detachOk, detachFail, reversePeers, reverseReject,
|
|
23
23
|
} from '../messages.gen.mjs';
|
|
24
24
|
import {
|
|
@@ -28,6 +28,9 @@ import { FILE_CHUNK_BYTES } from './fs.mjs';
|
|
|
28
28
|
import { MAX_ATTACHMENTS } from './sessions.mjs';
|
|
29
29
|
import { MCP_CALL_ID_FEATURE } from '../client.mjs';
|
|
30
30
|
import { RpcChannel, RPC_FEATURE, RPC_MAX_MESSAGE_PREFIX, rpcProtocolFeature } from '../rpc.mjs';
|
|
31
|
+
import { sealFrame, openFrame, ROLE_TAGS } from '../e2e-frame.mjs';
|
|
32
|
+
import { E2EResponder, E2E_FEATURE, E2E_SIGN_FEATURE } from '../e2e-exchange.mjs';
|
|
33
|
+
import { MULTI_OPERATOR_FEATURE } from './relay.mjs';
|
|
31
34
|
import { HOST_KEY_PREFIX, HOST_KEY_SIG_PREFIX, findClientNonce, hostKeyProofMessage, toHex } from '../host-key.mjs';
|
|
32
35
|
|
|
33
36
|
export const STREAM_ANNOUNCE = 'stream-announce';
|
|
@@ -42,6 +45,15 @@ export function hostFeatures(cfg, { dataStreams = true } = {}) {
|
|
|
42
45
|
const features = dataStreams ? [STREAM_ANNOUNCE] : [];
|
|
43
46
|
if (cfg.mcp) features.push(MCP_CALL_ID_FEATURE);
|
|
44
47
|
if (cfg.files) features.push('file-transfer', 'file-write', 'file-rename');
|
|
48
|
+
// End-to-end encryption (#90) is the virtual-mode EncryptedFrame layer, so it is offered where there are no
|
|
49
|
+
// client-opened streams: over a relay bridge.
|
|
50
|
+
// A relay carries a signed KeyExchange untouched, and (when configured) several operators per peer: peers read
|
|
51
|
+
// these from its ServerHello before relying on either, since an older relay would drop the connection on them.
|
|
52
|
+
if (cfg.relay) {
|
|
53
|
+
features.push(E2E_SIGN_FEATURE);
|
|
54
|
+
if (cfg.relay.maxOperatorsPerPeer > 1) features.push(MULTI_OPERATOR_FEATURE);
|
|
55
|
+
}
|
|
56
|
+
if (cfg.e2e && !dataStreams) features.push(E2E_FEATURE, ...(cfg.e2e.signKey ? [E2E_SIGN_FEATURE] : []));
|
|
45
57
|
if (cfg.rpc && dataStreams && cfg.rpc.protocols.size) {
|
|
46
58
|
features.push(RPC_FEATURE, ...[...cfg.rpc.protocols.keys()].map(rpcProtocolFeature), RPC_MAX_MESSAGE_PREFIX + cfg.rpc.maxMessageBytes);
|
|
47
59
|
}
|
|
@@ -70,6 +82,8 @@ const toBytes = (d) => (typeof d === 'string' ? enc.encode(d) : d);
|
|
|
70
82
|
* @param {object | null} cfg.hostKey - from loadHostKey
|
|
71
83
|
* @param {object | null} cfg.mcp - from createMcpHost
|
|
72
84
|
* @param {{ protocols: Map<string, Function>, maxMessageBytes: number, maxInflight: number } | null} cfg.rpc - from createRpcHost
|
|
85
|
+
* @param {{ signKey: CryptoKey | null, hybrid?: boolean } | null} cfg.e2e - answer KeyExchange and seal virtual-mode traffic (bridged connections only)
|
|
86
|
+
* @param {Record<string, Function> | undefined} cfg.extensions - handlers for application-defined (string-typed) messages
|
|
73
87
|
* @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
|
|
74
88
|
* @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
|
|
75
89
|
* @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
|
|
@@ -86,8 +100,9 @@ export function createConnectionFactory(cfg) {
|
|
|
86
100
|
* for a transport that already frames messages itself (a relay bridge, WebTransport). `authenticated`
|
|
87
101
|
* (`{ username, fingerprint }`) skips the handshake for a peer something else has already authenticated;
|
|
88
102
|
* `dataStreams: false` means there are no client-opened streams to bind, so exec output rides SessionData.
|
|
103
|
+
* `e2e` overrides `cfg.e2e` for this connection.
|
|
89
104
|
*/
|
|
90
|
-
return function attach({ send: sendBytes, sendMessage = null, remote = {}, authenticated = null, dataStreams = true, closeTransport = null }) {
|
|
105
|
+
return function attach({ send: sendBytes, sendMessage = null, remote = {}, authenticated = null, dataStreams = true, closeTransport = null, e2e: e2eOverride }) {
|
|
91
106
|
const cid = ++counter;
|
|
92
107
|
const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
|
|
93
108
|
const state = {
|
|
@@ -111,7 +126,39 @@ export function createConnectionFactory(cfg) {
|
|
|
111
126
|
|
|
112
127
|
const qmux = sendMessage ? null : new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
|
|
113
128
|
|
|
129
|
+
// ── End-to-end encryption (#90) ──────────────────────────────────
|
|
130
|
+
// An operator that sent KeyExchange for one of this connection's sessions gets that session's output sealed
|
|
131
|
+
// into EncryptedFrames and its input opened from them; plaintext input for it is ignored from then on (a relay
|
|
132
|
+
// must not be able to inject stdin around the encryption).
|
|
133
|
+
/** channel id -> the session id its OpenOk named (the AAD of its frames). */
|
|
134
|
+
const sessionOfChannel = new Map();
|
|
135
|
+
/** session id -> { key, sendCounter, recvCounter } for sessions with E2E on. */
|
|
136
|
+
const e2eSessions = new Map();
|
|
137
|
+
const MAX_E2E_SESSIONS = 64;
|
|
138
|
+
const e2eConfig = e2eOverride === undefined ? cfg.e2e : e2eOverride;
|
|
139
|
+
const responder = e2eConfig && !dataStreams ? new E2EResponder({ signKey: e2eConfig.signKey ?? null, hybrid: e2eConfig.hybrid !== false }) : null;
|
|
140
|
+
/** Outgoing messages in order, once E2E is on (sealing is async; order on the wire must not change). */
|
|
141
|
+
let outChain = Promise.resolve();
|
|
142
|
+
const e2eFor = (channelId) => e2eSessions.get(sessionOfChannel.get(channelId));
|
|
143
|
+
|
|
114
144
|
const send = (msg) => {
|
|
145
|
+
if (state.closed) return Promise.resolve();
|
|
146
|
+
if (msg.type === MSG.OPEN_OK && typeof msg.session_id === 'string') sessionOfChannel.set(msg.channel_id, msg.session_id);
|
|
147
|
+
if (e2eSessions.size === 0) return deliver(msg);
|
|
148
|
+
// E2E is on for something: everything goes through one chain, so a sealed frame cannot be overtaken
|
|
149
|
+
// by (or overtake) the Exit that follows it.
|
|
150
|
+
const done = outChain.then(async () => {
|
|
151
|
+
const st = msg.type === MSG.SESSION_DATA ? e2eFor(msg.channel_id) : undefined;
|
|
152
|
+
if (!st) return deliver(msg);
|
|
153
|
+
const sessionId = sessionOfChannel.get(msg.channel_id);
|
|
154
|
+
const { nonce, ciphertext } = await sealFrame(st.key, sessionId, ROLE_TAGS.responder, st.sendCounter++, msg.data);
|
|
155
|
+
return deliver(encryptedFrameMsg({ channelId: msg.channel_id, nonce, ciphertext, sessionId }));
|
|
156
|
+
});
|
|
157
|
+
outChain = done.catch(() => {});
|
|
158
|
+
return done.catch(() => {});
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const deliver = (msg) => {
|
|
115
162
|
if (state.closed) return Promise.resolve();
|
|
116
163
|
if (sendMessage) {
|
|
117
164
|
try { return Promise.resolve(sendMessage(msg)).catch(() => {}); } catch { return Promise.resolve(); }
|
|
@@ -129,6 +176,8 @@ export function createConnectionFactory(cfg) {
|
|
|
129
176
|
// everything else dies with it.
|
|
130
177
|
for (const ch of [...channels.values()]) (ch.detach ?? ch.kill)?.call(ch);
|
|
131
178
|
channels.clear();
|
|
179
|
+
responder?.close();
|
|
180
|
+
e2eSessions.clear();
|
|
132
181
|
for (const c of mcpCalls) c.abort(new Error('connection closed'));
|
|
133
182
|
mcpCalls.clear();
|
|
134
183
|
for (const w of fileWrites.values()) clearTimeout(w.timer);
|
|
@@ -179,7 +228,12 @@ export function createConnectionFactory(cfg) {
|
|
|
179
228
|
switch (m.type) {
|
|
180
229
|
case MSG.PING: return send(pong({ id: m.id }));
|
|
181
230
|
case MSG.OPEN: return handleOpen(m);
|
|
182
|
-
case MSG.SESSION_DATA:
|
|
231
|
+
case MSG.SESSION_DATA:
|
|
232
|
+
if (e2eFor(m.channel_id)) { log(`ignored plaintext SessionData on E2E channel ${m.channel_id}`); return; }
|
|
233
|
+
channels.get(m.channel_id)?.input?.(m.data);
|
|
234
|
+
return;
|
|
235
|
+
case MSG.KEY_EXCHANGE: return handleKeyExchange(m);
|
|
236
|
+
case MSG.ENCRYPTED_FRAME: return handleEncryptedFrame(m);
|
|
183
237
|
case MSG.RESIZE: channels.get(m.channel_id)?.resize?.(m.cols, m.rows); return;
|
|
184
238
|
case MSG.SIGNAL: channels.get(m.channel_id)?.signal?.(String(m.signal ?? '')); return;
|
|
185
239
|
case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
|
|
@@ -193,8 +247,44 @@ export function createConnectionFactory(cfg) {
|
|
|
193
247
|
// Not awaited: the handler chain is serial, and a slow tool must not hold up
|
|
194
248
|
// the next call (or a Close) behind it.
|
|
195
249
|
case MSG.MCP_CALL: handleMcpCall(m); return;
|
|
196
|
-
default:
|
|
250
|
+
default:
|
|
251
|
+
if (typeof m.type === 'string' && Object.hasOwn(cfg.extensions ?? {}, m.type) && typeof cfg.extensions[m.type] === 'function') {
|
|
252
|
+
try { await cfg.extensions[m.type](m, { username: state.username, fingerprint: state.fingerprint, send }); } catch (e) { log(`extension "${m.type}" threw: ${e.message}`); }
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
log(`ignored message type ${typeof m.type === 'number' ? `0x${m.type.toString(16)}` : JSON.stringify(String(m.type)).slice(0, 80)}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function handleKeyExchange(m) {
|
|
260
|
+
if (!responder) { log('ignored KeyExchange (end-to-end encryption is not enabled here)'); return; }
|
|
261
|
+
// Only for a session this connection actually has: the id is the AAD of every frame, and it bounds the state kept.
|
|
262
|
+
const known = typeof m.session_id === 'string' && [...sessionOfChannel.values()].includes(m.session_id);
|
|
263
|
+
if (!known) { log('ignored KeyExchange for a session this connection does not have'); return; }
|
|
264
|
+
if (!e2eSessions.has(m.session_id) && e2eSessions.size >= MAX_E2E_SESSIONS) { log('ignored KeyExchange (too many encrypted sessions)'); return; }
|
|
265
|
+
let result;
|
|
266
|
+
try { result = await responder.respond(m, send); } catch (e) { log(`KeyExchange refused: ${e.message}`); return; }
|
|
267
|
+
// Sealing starts after the reply is on its way, so the operator never gets a frame before it has the key.
|
|
268
|
+
if (result.key) {
|
|
269
|
+
e2eSessions.set(result.sessionId, { key: result.key, sendCounter: 0, recvCounter: 0 });
|
|
270
|
+
log(`end-to-end encryption on for session ${result.sessionId.slice(0, 8)}${result.hybrid ? ' (hybrid)' : ''}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function handleEncryptedFrame(m) {
|
|
275
|
+
const st = e2eSessions.get(m.session_id);
|
|
276
|
+
// The frame must name the session its channel belongs to, or it could be spliced onto another channel.
|
|
277
|
+
if (!st || sessionOfChannel.get(m.channel_id) !== m.session_id) { log('dropped an EncryptedFrame for a session without E2E'); return; }
|
|
278
|
+
let plain;
|
|
279
|
+
try {
|
|
280
|
+
plain = await openFrame(st.key, m.session_id, st.recvCounter, { nonce: m.nonce, ciphertext: m.ciphertext, expectedRoleTag: ROLE_TAGS.initiator });
|
|
281
|
+
} catch (e) {
|
|
282
|
+
// Garbage (a replay, a tampered or foreign frame) neither reaches the process nor consumes a counter.
|
|
283
|
+
log(`dropped an EncryptedFrame: ${e.message}`);
|
|
284
|
+
return;
|
|
197
285
|
}
|
|
286
|
+
st.recvCounter += 1;
|
|
287
|
+
channels.get(m.channel_id)?.input?.(plain);
|
|
198
288
|
}
|
|
199
289
|
|
|
200
290
|
async function refuse(reason) {
|
package/src/server/index.d.ts
CHANGED
|
@@ -196,8 +196,25 @@ export interface WshServerRelayOptions {
|
|
|
196
196
|
from: { username: string; fingerprint: string },
|
|
197
197
|
to: WshRelayPeer,
|
|
198
198
|
) => boolean | Promise<boolean>;
|
|
199
|
+
/**
|
|
200
|
+
* Called (not awaited; a throw is logged) when an authenticated operator asks to connect to a peer that is not
|
|
201
|
+
* registered, with the operator, the fingerprint it asked for and the `ReverseConnect`'s claimed `username`. It
|
|
202
|
+
* never changes the answer (the operator is still told `no such peer`); it lets the host reach the peer another
|
|
203
|
+
* way, e.g. a Web Push notification.
|
|
204
|
+
*/
|
|
205
|
+
onUnreachable?: (
|
|
206
|
+
from: { username: string; fingerprint: string },
|
|
207
|
+
targetFingerprint: string,
|
|
208
|
+
request: { username?: string },
|
|
209
|
+
) => unknown;
|
|
199
210
|
/** Registered peers at once (default 1024). */
|
|
200
211
|
maxPeers?: number;
|
|
212
|
+
/**
|
|
213
|
+
* Operators bridged to one peer at once (default 1). Above 1, a peer is held to one operator unless it stated
|
|
214
|
+
* `relay-multi-operator` in its `ReverseAccept.features` (it then addresses replies with
|
|
215
|
+
* `RelayForward.to_fingerprint` and is sent a `ReverseClose` when one operator leaves instead of being closed).
|
|
216
|
+
*/
|
|
217
|
+
maxOperatorsPerPeer?: number;
|
|
201
218
|
/** How long a `ReverseConnect` waits for its peer to answer before the operator is rejected (default 8000). */
|
|
202
219
|
connectTimeoutMs?: number;
|
|
203
220
|
}
|
|
@@ -317,6 +334,16 @@ export interface WshServerOptions {
|
|
|
317
334
|
* Default deny -- give both `canRegister` and `canConnect`. Off by default.
|
|
318
335
|
*/
|
|
319
336
|
relay?: WshServerRelayOptions;
|
|
337
|
+
/**
|
|
338
|
+
* Serve the WebSocket listener over TLS (`wss://`) on the same port; these are the options of `https.createServer`.
|
|
339
|
+
* The WebTransport listener has its own certificate (`webTransport`). Off (plain `ws://`) by default.
|
|
340
|
+
*/
|
|
341
|
+
tls?: { cert: string | Uint8Array; key: string | Uint8Array; ca?: string | Uint8Array; passphrase?: string };
|
|
342
|
+
/**
|
|
343
|
+
* Handlers for application-defined control messages: one whose `type` is a string (protocol messages are numeric, so
|
|
344
|
+
* there is no collision) from an authenticated connection is handed to `extensions[type]`. A throw is logged.
|
|
345
|
+
*/
|
|
346
|
+
extensions?: Record<string, (msg: Record<string, unknown>, ctx: { username: string; fingerprint: string | null; send: (msg: object) => unknown }) => unknown>;
|
|
320
347
|
/**
|
|
321
348
|
* Also listen for WebTransport (HTTP/3 over UDP) clients -- the same auth, exec, pty, fs, mcp, sessions and relay as the
|
|
322
349
|
* WebSocket listener, with real independent streams. Needs the optional peers `@fails-components/webtransport` and
|
|
@@ -421,6 +448,23 @@ export interface WshReverseHostOptions {
|
|
|
421
448
|
/** Extra options for the relay connection (`expectHostKey`, `knownHosts`, `trustOnFirstUse`, ...). */
|
|
422
449
|
connect?: Record<string, unknown>;
|
|
423
450
|
peerType?: string;
|
|
451
|
+
/**
|
|
452
|
+
* Operators served at once (default 1). Above 1 the host states `relay-multi-operator`, addresses each reply
|
|
453
|
+
* to its operator and handles `ReverseClose`; each operator gets its own connection state and is passed through
|
|
454
|
+
* `accept` on its own identity. Needs `reportFeatures` and a relay with `maxOperatorsPerPeer` above 1; any other
|
|
455
|
+
* relay serves it one operator, as before.
|
|
456
|
+
*/
|
|
457
|
+
maxOperators?: number;
|
|
458
|
+
/** State this host's features in `ReverseAccept.features` (default true). */
|
|
459
|
+
reportFeatures?: boolean;
|
|
460
|
+
/**
|
|
461
|
+
* End-to-end encryption for the bridged operator (default on; `false` turns it off). The host answers an
|
|
462
|
+
* operator's `KeyExchange` for a session it opened, seals that session's output into `EncryptedFrame`s and opens its
|
|
463
|
+
* input from them. `sign`: sign the reply with `keyPair` so the operator can authenticate this host
|
|
464
|
+
* (`initiateE2E(..., { verifyPeer })`); `'auto'` (default) signs when the relay's `ServerHello` carries `e2e-sign`.
|
|
465
|
+
* `hybrid` (default true) offers X25519+ML-KEM-768 when asked and available.
|
|
466
|
+
*/
|
|
467
|
+
e2e?: false | { sign?: boolean | 'auto'; hybrid?: boolean };
|
|
424
468
|
onLog?: (line: string) => void;
|
|
425
469
|
}
|
|
426
470
|
|
|
@@ -433,6 +477,8 @@ export interface WshReverseHost {
|
|
|
433
477
|
readonly fingerprint: string | null;
|
|
434
478
|
/** Is the relay connection up right now? */
|
|
435
479
|
readonly connected: boolean;
|
|
480
|
+
/** Fingerprints of the operators bridged right now. */
|
|
481
|
+
readonly operators: string[];
|
|
436
482
|
}
|
|
437
483
|
|
|
438
484
|
/**
|
package/src/server/index.mjs
CHANGED
|
@@ -39,10 +39,12 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
|
|
|
39
39
|
* SHA-256 through `webTransport()` / `certificateHashes()` for the client's `serverCertificateHashes`, and rotates it
|
|
40
40
|
* before it expires (`rotateCertificate()`). Needs the optional peers
|
|
41
41
|
* `@fails-components/webtransport` and `@fails-components/webtransport-transport-http3-quiche`. Off unless given.
|
|
42
|
-
* @param {{ canRegister?: Function, canConnect?: Function, maxPeers?: number, connectTimeoutMs?: number }} [options.relay]
|
|
42
|
+
* @param {{ canRegister?: Function, canConnect?: Function, maxPeers?: number, maxOperatorsPerPeer?: number, connectTimeoutMs?: number }} [options.relay]
|
|
43
43
|
* Act as a relay: peers register (`ReverseRegister`, a signed record), operators list and connect to them
|
|
44
44
|
* (`ReverseList` / `ReverseConnect`) and traffic is carried between them as `RelayForward`. Default deny:
|
|
45
45
|
* `canRegister(who, record)` and `canConnect(from, to)` must both be given and return true. See `createReverseHost`.
|
|
46
|
+
* One operator per peer unless `maxOperatorsPerPeer` is raised -- and then only for peers that state
|
|
47
|
+
* `relay-multi-operator` (`createReverseHost({ maxOperators })`).
|
|
46
48
|
* @param {true | { detachTtlMs?: number, maxDetached?: number, ringBytes?: number, sessionSecret?: string | Uint8Array }} [options.sessions]
|
|
47
49
|
* Keep pty/exec sessions alive across disconnects so a client can `resumeSession()` / `attachSession()`
|
|
48
50
|
* them: a per-server registry, a ring buffer of the newest `ringBytes` (default 1 MiB) of output, and
|
|
@@ -58,14 +60,22 @@ export { generateSelfSignedCertificate } from './self-signed.mjs';
|
|
|
58
60
|
* identity, advertised (with proof of possession) so clients can pin it. See `src/host-key.mjs`.
|
|
59
61
|
* @param {object} [options.auth.rateLimit] - Password-failure throttle: `{ maxFailures=5, windowMs=60000,
|
|
60
62
|
* lockoutMs=60000, failureDelayMs=250, key(info) }` (`key` defaults to the peer address).
|
|
63
|
+
* @param {{ cert: string | Buffer, key: string | Buffer, ca?: string | Buffer, passphrase?: string }} [options.tls] - Serve the
|
|
64
|
+
* WebSocket listener over TLS (`wss://`) on the same port: the options of `https.createServer`. Off (plain `ws://`) unless given.
|
|
65
|
+
* The WebTransport listener has its own certificate (`webTransport.cert` / `selfSigned`).
|
|
66
|
+
* @param {Record<string, (msg: object, ctx: { username: string, fingerprint: string | null, send: Function }) => unknown>} [options.extensions]
|
|
67
|
+
* Handlers for application-defined messages: a control message whose `type` is a *string* (every protocol message
|
|
68
|
+
* has a numeric type, so these cannot collide) from an authenticated connection is passed to `extensions[type]`.
|
|
69
|
+
* A handler that throws is logged and ignored. Off unless given.
|
|
61
70
|
* @param {number} [options.bindTimeoutMs=3000]
|
|
62
71
|
* @param {(line: string) => void} [options.onLog]
|
|
63
72
|
* @returns {{ listen(): Promise<{address: string, port: number}>, close(): Promise<void>, address(): ({address: string, port: number} | null) }}
|
|
64
73
|
*/
|
|
65
74
|
export function createWshServer({
|
|
66
|
-
host = '127.0.0.1', port = 0, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, webTransport, bindTimeoutMs = 3000, onLog = () => {},
|
|
75
|
+
host = '127.0.0.1', port = 0, tls, auth, exec, pty, fs, hostKey, mcp, rpc, rpcMaxMessageBytes, rpcMaxInflight, sessions, sessionSecret, relay, extensions, webTransport, bindTimeoutMs = 3000, onLog = () => {},
|
|
67
76
|
} = {}) {
|
|
68
77
|
let wss = null;
|
|
78
|
+
let httpsServer = null;
|
|
69
79
|
let bound = null;
|
|
70
80
|
|
|
71
81
|
const { execRunner, execOptions, pty: ptyConfig, files, mcp: mcpHost } = buildBackends({ exec, pty, fs, mcp });
|
|
@@ -76,6 +86,9 @@ export function createWshServer({
|
|
|
76
86
|
key: typeof rl.key === 'function' ? rl.key : ({ address }) => String(address ?? 'unknown'),
|
|
77
87
|
failureDelayMs: rl.failureDelayMs ?? 250,
|
|
78
88
|
};
|
|
89
|
+
if (relay && relay.maxOperatorsPerPeer !== undefined && (!Number.isSafeInteger(relay.maxOperatorsPerPeer) || relay.maxOperatorsPerPeer < 1)) {
|
|
90
|
+
throw new TypeError('createWshServer: relay.maxOperatorsPerPeer must be a positive integer');
|
|
91
|
+
}
|
|
79
92
|
const rpcHost = rpc ? createRpcHost(rpc, { files, maxMessageBytes: rpcMaxMessageBytes, maxInflight: rpcMaxInflight }) : null;
|
|
80
93
|
let host_ = null;
|
|
81
94
|
let registry = null;
|
|
@@ -105,12 +118,19 @@ export function createWshServer({
|
|
|
105
118
|
});
|
|
106
119
|
}
|
|
107
120
|
if (relay) hub = new RelayHub({ ...relay, log: onLog });
|
|
121
|
+
if (extensions !== undefined && (extensions === null || typeof extensions !== 'object')) throw new TypeError('createWshServer: extensions must be an object of handlers');
|
|
108
122
|
const attach = createConnectionFactory({
|
|
109
123
|
authorize, execRunner, execOptions, pty: ptyConfig, files, bindTimeoutMs, log: onLog,
|
|
110
|
-
methods, hostKey: host_, rateLimit, mcp: mcpHost, rpc: rpcHost, sessions: registry, relay: hub,
|
|
124
|
+
methods, hostKey: host_, rateLimit, extensions, mcp: mcpHost, rpc: rpcHost, sessions: registry, relay: hub,
|
|
111
125
|
});
|
|
112
126
|
|
|
113
|
-
|
|
127
|
+
if (tls) {
|
|
128
|
+
const { createServer } = await import('node:https');
|
|
129
|
+
httpsServer = createServer(tls);
|
|
130
|
+
wss = new WebSocketServer({ server: httpsServer });
|
|
131
|
+
} else {
|
|
132
|
+
wss = new WebSocketServer({ host, port });
|
|
133
|
+
}
|
|
114
134
|
wss.on('connection', (ws, req) => {
|
|
115
135
|
const conn = attach({
|
|
116
136
|
send: (b) => { if (ws.readyState === 1) ws.send(b); },
|
|
@@ -125,10 +145,15 @@ export function createWshServer({
|
|
|
125
145
|
ws.on('error', () => {});
|
|
126
146
|
});
|
|
127
147
|
await new Promise((resolve, reject) => {
|
|
128
|
-
|
|
129
|
-
|
|
148
|
+
if (httpsServer) {
|
|
149
|
+
httpsServer.once('error', reject);
|
|
150
|
+
httpsServer.listen(port, host, () => { httpsServer.off('error', reject); resolve(); });
|
|
151
|
+
} else {
|
|
152
|
+
wss.once('listening', resolve);
|
|
153
|
+
wss.once('error', reject);
|
|
154
|
+
}
|
|
130
155
|
});
|
|
131
|
-
const a = wss.address();
|
|
156
|
+
const a = (httpsServer ?? wss).address();
|
|
132
157
|
bound = { address: a.address, port: a.port };
|
|
133
158
|
if (webTransport) {
|
|
134
159
|
try {
|
|
@@ -150,6 +175,7 @@ export function createWshServer({
|
|
|
150
175
|
await wt?.close();
|
|
151
176
|
wt = null;
|
|
152
177
|
await new Promise((resolve) => server.close(resolve));
|
|
178
|
+
if (httpsServer) { const h = httpsServer; httpsServer = null; await new Promise((resolve) => h.close(resolve)); }
|
|
153
179
|
registry?.closeAll();
|
|
154
180
|
registry = null;
|
|
155
181
|
hub?.closeAll();
|