@johnhenry/wsh 0.27.0 → 0.28.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 +112 -40
- 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 +87 -3
- package/src/server/index.d.ts +25 -0
- package/src/server/index.mjs +6 -1
- package/src/server/relay.mjs +103 -24
- 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,7 @@ 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)
|
|
73
86
|
* @param {object | null} cfg.relay - a RelayHub (see relay.mjs); null = not a relay
|
|
74
87
|
* @param {object | null} cfg.sessions - a SessionRegistry (see sessions.mjs); null = sessions die with their connection
|
|
75
88
|
* @param {{ limiter: object, key: Function, failureDelayMs: number } | null} cfg.rateLimit - password throttle
|
|
@@ -86,8 +99,9 @@ export function createConnectionFactory(cfg) {
|
|
|
86
99
|
* for a transport that already frames messages itself (a relay bridge, WebTransport). `authenticated`
|
|
87
100
|
* (`{ username, fingerprint }`) skips the handshake for a peer something else has already authenticated;
|
|
88
101
|
* `dataStreams: false` means there are no client-opened streams to bind, so exec output rides SessionData.
|
|
102
|
+
* `e2e` overrides `cfg.e2e` for this connection.
|
|
89
103
|
*/
|
|
90
|
-
return function attach({ send: sendBytes, sendMessage = null, remote = {}, authenticated = null, dataStreams = true, closeTransport = null }) {
|
|
104
|
+
return function attach({ send: sendBytes, sendMessage = null, remote = {}, authenticated = null, dataStreams = true, closeTransport = null, e2e: e2eOverride }) {
|
|
91
105
|
const cid = ++counter;
|
|
92
106
|
const log = (m) => cfg.log(`[conn ${cid}] ${m}`);
|
|
93
107
|
const state = {
|
|
@@ -111,7 +125,39 @@ export function createConnectionFactory(cfg) {
|
|
|
111
125
|
|
|
112
126
|
const qmux = sendMessage ? null : new QMuxConnection({ isClient: false, send: (b) => { if (!state.closed) sendBytes(b); } });
|
|
113
127
|
|
|
128
|
+
// ── End-to-end encryption (#90) ──────────────────────────────────
|
|
129
|
+
// An operator that sent KeyExchange for one of this connection's sessions gets that session's output sealed
|
|
130
|
+
// into EncryptedFrames and its input opened from them; plaintext input for it is ignored from then on (a relay
|
|
131
|
+
// must not be able to inject stdin around the encryption).
|
|
132
|
+
/** channel id -> the session id its OpenOk named (the AAD of its frames). */
|
|
133
|
+
const sessionOfChannel = new Map();
|
|
134
|
+
/** session id -> { key, sendCounter, recvCounter } for sessions with E2E on. */
|
|
135
|
+
const e2eSessions = new Map();
|
|
136
|
+
const MAX_E2E_SESSIONS = 64;
|
|
137
|
+
const e2eConfig = e2eOverride === undefined ? cfg.e2e : e2eOverride;
|
|
138
|
+
const responder = e2eConfig && !dataStreams ? new E2EResponder({ signKey: e2eConfig.signKey ?? null, hybrid: e2eConfig.hybrid !== false }) : null;
|
|
139
|
+
/** Outgoing messages in order, once E2E is on (sealing is async; order on the wire must not change). */
|
|
140
|
+
let outChain = Promise.resolve();
|
|
141
|
+
const e2eFor = (channelId) => e2eSessions.get(sessionOfChannel.get(channelId));
|
|
142
|
+
|
|
114
143
|
const send = (msg) => {
|
|
144
|
+
if (state.closed) return Promise.resolve();
|
|
145
|
+
if (msg.type === MSG.OPEN_OK && typeof msg.session_id === 'string') sessionOfChannel.set(msg.channel_id, msg.session_id);
|
|
146
|
+
if (e2eSessions.size === 0) return deliver(msg);
|
|
147
|
+
// E2E is on for something: everything goes through one chain, so a sealed frame cannot be overtaken
|
|
148
|
+
// by (or overtake) the Exit that follows it.
|
|
149
|
+
const done = outChain.then(async () => {
|
|
150
|
+
const st = msg.type === MSG.SESSION_DATA ? e2eFor(msg.channel_id) : undefined;
|
|
151
|
+
if (!st) return deliver(msg);
|
|
152
|
+
const sessionId = sessionOfChannel.get(msg.channel_id);
|
|
153
|
+
const { nonce, ciphertext } = await sealFrame(st.key, sessionId, ROLE_TAGS.responder, st.sendCounter++, msg.data);
|
|
154
|
+
return deliver(encryptedFrameMsg({ channelId: msg.channel_id, nonce, ciphertext, sessionId }));
|
|
155
|
+
});
|
|
156
|
+
outChain = done.catch(() => {});
|
|
157
|
+
return done.catch(() => {});
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const deliver = (msg) => {
|
|
115
161
|
if (state.closed) return Promise.resolve();
|
|
116
162
|
if (sendMessage) {
|
|
117
163
|
try { return Promise.resolve(sendMessage(msg)).catch(() => {}); } catch { return Promise.resolve(); }
|
|
@@ -129,6 +175,8 @@ export function createConnectionFactory(cfg) {
|
|
|
129
175
|
// everything else dies with it.
|
|
130
176
|
for (const ch of [...channels.values()]) (ch.detach ?? ch.kill)?.call(ch);
|
|
131
177
|
channels.clear();
|
|
178
|
+
responder?.close();
|
|
179
|
+
e2eSessions.clear();
|
|
132
180
|
for (const c of mcpCalls) c.abort(new Error('connection closed'));
|
|
133
181
|
mcpCalls.clear();
|
|
134
182
|
for (const w of fileWrites.values()) clearTimeout(w.timer);
|
|
@@ -179,7 +227,12 @@ export function createConnectionFactory(cfg) {
|
|
|
179
227
|
switch (m.type) {
|
|
180
228
|
case MSG.PING: return send(pong({ id: m.id }));
|
|
181
229
|
case MSG.OPEN: return handleOpen(m);
|
|
182
|
-
case MSG.SESSION_DATA:
|
|
230
|
+
case MSG.SESSION_DATA:
|
|
231
|
+
if (e2eFor(m.channel_id)) { log(`ignored plaintext SessionData on E2E channel ${m.channel_id}`); return; }
|
|
232
|
+
channels.get(m.channel_id)?.input?.(m.data);
|
|
233
|
+
return;
|
|
234
|
+
case MSG.KEY_EXCHANGE: return handleKeyExchange(m);
|
|
235
|
+
case MSG.ENCRYPTED_FRAME: return handleEncryptedFrame(m);
|
|
183
236
|
case MSG.RESIZE: channels.get(m.channel_id)?.resize?.(m.cols, m.rows); return;
|
|
184
237
|
case MSG.SIGNAL: channels.get(m.channel_id)?.signal?.(String(m.signal ?? '')); return;
|
|
185
238
|
case MSG.CLOSE: { const ch = channels.get(m.channel_id); if (ch) { ch.kill?.(); } return; }
|
|
@@ -197,6 +250,37 @@ export function createConnectionFactory(cfg) {
|
|
|
197
250
|
}
|
|
198
251
|
}
|
|
199
252
|
|
|
253
|
+
async function handleKeyExchange(m) {
|
|
254
|
+
if (!responder) { log('ignored KeyExchange (end-to-end encryption is not enabled here)'); return; }
|
|
255
|
+
// Only for a session this connection actually has: the id is the AAD of every frame, and it bounds the state kept.
|
|
256
|
+
const known = typeof m.session_id === 'string' && [...sessionOfChannel.values()].includes(m.session_id);
|
|
257
|
+
if (!known) { log('ignored KeyExchange for a session this connection does not have'); return; }
|
|
258
|
+
if (!e2eSessions.has(m.session_id) && e2eSessions.size >= MAX_E2E_SESSIONS) { log('ignored KeyExchange (too many encrypted sessions)'); return; }
|
|
259
|
+
let result;
|
|
260
|
+
try { result = await responder.respond(m, send); } catch (e) { log(`KeyExchange refused: ${e.message}`); return; }
|
|
261
|
+
// Sealing starts after the reply is on its way, so the operator never gets a frame before it has the key.
|
|
262
|
+
if (result.key) {
|
|
263
|
+
e2eSessions.set(result.sessionId, { key: result.key, sendCounter: 0, recvCounter: 0 });
|
|
264
|
+
log(`end-to-end encryption on for session ${result.sessionId.slice(0, 8)}${result.hybrid ? ' (hybrid)' : ''}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function handleEncryptedFrame(m) {
|
|
269
|
+
const st = e2eSessions.get(m.session_id);
|
|
270
|
+
// The frame must name the session its channel belongs to, or it could be spliced onto another channel.
|
|
271
|
+
if (!st || sessionOfChannel.get(m.channel_id) !== m.session_id) { log('dropped an EncryptedFrame for a session without E2E'); return; }
|
|
272
|
+
let plain;
|
|
273
|
+
try {
|
|
274
|
+
plain = await openFrame(st.key, m.session_id, st.recvCounter, { nonce: m.nonce, ciphertext: m.ciphertext, expectedRoleTag: ROLE_TAGS.initiator });
|
|
275
|
+
} catch (e) {
|
|
276
|
+
// Garbage (a replay, a tampered or foreign frame) neither reaches the process nor consumes a counter.
|
|
277
|
+
log(`dropped an EncryptedFrame: ${e.message}`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
st.recvCounter += 1;
|
|
281
|
+
channels.get(m.channel_id)?.input?.(plain);
|
|
282
|
+
}
|
|
283
|
+
|
|
200
284
|
async function refuse(reason) {
|
|
201
285
|
log(`AUTH_FAIL: ${reason}`);
|
|
202
286
|
await send(authFail({ reason }));
|
package/src/server/index.d.ts
CHANGED
|
@@ -198,6 +198,12 @@ export interface WshServerRelayOptions {
|
|
|
198
198
|
) => boolean | Promise<boolean>;
|
|
199
199
|
/** Registered peers at once (default 1024). */
|
|
200
200
|
maxPeers?: number;
|
|
201
|
+
/**
|
|
202
|
+
* Operators bridged to one peer at once (default 1). Above 1, a peer is held to one operator unless it stated
|
|
203
|
+
* `relay-multi-operator` in its `ReverseAccept.features` (it then addresses replies with
|
|
204
|
+
* `RelayForward.to_fingerprint` and is sent a `ReverseClose` when one operator leaves instead of being closed).
|
|
205
|
+
*/
|
|
206
|
+
maxOperatorsPerPeer?: number;
|
|
201
207
|
/** How long a `ReverseConnect` waits for its peer to answer before the operator is rejected (default 8000). */
|
|
202
208
|
connectTimeoutMs?: number;
|
|
203
209
|
}
|
|
@@ -421,6 +427,23 @@ export interface WshReverseHostOptions {
|
|
|
421
427
|
/** Extra options for the relay connection (`expectHostKey`, `knownHosts`, `trustOnFirstUse`, ...). */
|
|
422
428
|
connect?: Record<string, unknown>;
|
|
423
429
|
peerType?: string;
|
|
430
|
+
/**
|
|
431
|
+
* Operators served at once (default 1). Above 1 the host states `relay-multi-operator`, addresses each reply
|
|
432
|
+
* to its operator and handles `ReverseClose`; each operator gets its own connection state and is passed through
|
|
433
|
+
* `accept` on its own identity. Needs `reportFeatures` and a relay with `maxOperatorsPerPeer` above 1; any other
|
|
434
|
+
* relay serves it one operator, as before.
|
|
435
|
+
*/
|
|
436
|
+
maxOperators?: number;
|
|
437
|
+
/** State this host's features in `ReverseAccept.features` (default true). */
|
|
438
|
+
reportFeatures?: boolean;
|
|
439
|
+
/**
|
|
440
|
+
* End-to-end encryption for the bridged operator (default on; `false` turns it off). The host answers an
|
|
441
|
+
* operator's `KeyExchange` for a session it opened, seals that session's output into `EncryptedFrame`s and opens its
|
|
442
|
+
* input from them. `sign`: sign the reply with `keyPair` so the operator can authenticate this host
|
|
443
|
+
* (`initiateE2E(..., { verifyPeer })`); `'auto'` (default) signs when the relay's `ServerHello` carries `e2e-sign`.
|
|
444
|
+
* `hybrid` (default true) offers X25519+ML-KEM-768 when asked and available.
|
|
445
|
+
*/
|
|
446
|
+
e2e?: false | { sign?: boolean | 'auto'; hybrid?: boolean };
|
|
424
447
|
onLog?: (line: string) => void;
|
|
425
448
|
}
|
|
426
449
|
|
|
@@ -433,6 +456,8 @@ export interface WshReverseHost {
|
|
|
433
456
|
readonly fingerprint: string | null;
|
|
434
457
|
/** Is the relay connection up right now? */
|
|
435
458
|
readonly connected: boolean;
|
|
459
|
+
/** Fingerprints of the operators bridged right now. */
|
|
460
|
+
readonly operators: string[];
|
|
436
461
|
}
|
|
437
462
|
|
|
438
463
|
/**
|
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
|
|
@@ -76,6 +78,9 @@ export function createWshServer({
|
|
|
76
78
|
key: typeof rl.key === 'function' ? rl.key : ({ address }) => String(address ?? 'unknown'),
|
|
77
79
|
failureDelayMs: rl.failureDelayMs ?? 250,
|
|
78
80
|
};
|
|
81
|
+
if (relay && relay.maxOperatorsPerPeer !== undefined && (!Number.isSafeInteger(relay.maxOperatorsPerPeer) || relay.maxOperatorsPerPeer < 1)) {
|
|
82
|
+
throw new TypeError('createWshServer: relay.maxOperatorsPerPeer must be a positive integer');
|
|
83
|
+
}
|
|
79
84
|
const rpcHost = rpc ? createRpcHost(rpc, { files, maxMessageBytes: rpcMaxMessageBytes, maxInflight: rpcMaxInflight }) : null;
|
|
80
85
|
let host_ = null;
|
|
81
86
|
let registry = null;
|
package/src/server/relay.mjs
CHANGED
|
@@ -12,15 +12,22 @@
|
|
|
12
12
|
* Nothing is open by default: `canRegister` and `canConnect` both default to
|
|
13
13
|
* "no". `from_fingerprint` is always the sender's own authenticated key, set
|
|
14
14
|
* here and never taken from a payload; only the spec's `forwardable` message
|
|
15
|
-
* types cross a bridge.
|
|
15
|
+
* types cross a bridge.
|
|
16
|
+
*
|
|
17
|
+
* By default there is one operator per peer, and the bridge lasts as long as
|
|
16
18
|
* both connections: when either ends the hub closes the other, so no
|
|
17
19
|
* half-bridged state (and no processes belonging to a departed operator)
|
|
18
|
-
* outlives it.
|
|
20
|
+
* outlives it. With `maxOperatorsPerPeer` > 1 a peer that states
|
|
21
|
+
* `relay-multi-operator` in its ReverseAccept may be bridged to several
|
|
22
|
+
* operators at once (#89): it addresses each reply with
|
|
23
|
+
* `RelayForward.to_fingerprint`, and is told with a `ReverseClose` when one
|
|
24
|
+
* operator leaves (instead of being closed). A peer that never stated it is
|
|
25
|
+
* still held to one operator, and still closed when its operator leaves.
|
|
19
26
|
*/
|
|
20
27
|
|
|
21
28
|
import { cborEncode, cborDecode } from '../cbor.mjs';
|
|
22
29
|
import { fingerprint as fingerprintOf, importPublicKeyRaw, verifyPeerRecord } from '../auth.mjs';
|
|
23
|
-
import { MSG, isRelayForwardable, relayForward, reversePeers, reverseReject } from '../messages.gen.mjs';
|
|
30
|
+
import { MSG, isRelayForwardable, relayForward, reversePeers, reverseReject, reverseClose } from '../messages.gen.mjs';
|
|
24
31
|
|
|
25
32
|
export const DEFAULT_MAX_PEERS = 1024;
|
|
26
33
|
export const DEFAULT_CONNECT_TIMEOUT_MS = 8000;
|
|
@@ -32,7 +39,16 @@ const MAX_FEATURE_LENGTH = 128;
|
|
|
32
39
|
const SHORT_PREFIX_MIN = 8;
|
|
33
40
|
|
|
34
41
|
/** `ReverseReject.reason` for a peer that already has an operator (or a request awaiting its answer). */
|
|
42
|
+
/** `ReverseAccept.features` entry by which a peer says it serves several operators (addresses replies, understands `ReverseClose`). */
|
|
43
|
+
export const MULTI_OPERATOR_FEATURE = 'relay-multi-operator';
|
|
44
|
+
/** `ReverseReject.reason` for a peer that is answering another operator's request right now. */
|
|
45
|
+
export const BUSY_ANSWERING = 'busy: this peer is answering another operator\'s request (retry in a moment)';
|
|
46
|
+
|
|
35
47
|
export const BUSY_PEER = 'busy: this peer already has an operator (the relay bridges one operator per peer at a time; retry when it leaves)';
|
|
48
|
+
/** `ReverseReject.reason` for a multi-operator peer already at the relay's operator cap. */
|
|
49
|
+
export function busyPeerFull(max) {
|
|
50
|
+
return `busy: this peer already has its maximum of ${max} operators (retry when one leaves)`;
|
|
51
|
+
}
|
|
36
52
|
/** `ReverseReject.reason` for an operator that already has a bridge, or a request in flight. */
|
|
37
53
|
export const BUSY_OPERATOR = 'busy: you already have a bridge or a pending connect on this relay (one at a time)';
|
|
38
54
|
|
|
@@ -50,17 +66,23 @@ const isStr = (v, max) => typeof v === 'string' && v.length <= max;
|
|
|
50
66
|
export class RelayHub {
|
|
51
67
|
/** @type {Map<string, { handle: RelayHandle, meta: object }>} */
|
|
52
68
|
#peers = new Map();
|
|
53
|
-
/** @type {Map<RelayHandle, RelayHandle>} */
|
|
54
|
-
#
|
|
69
|
+
/** operator -> the peer it is bridged to (an operator has one bridge). @type {Map<RelayHandle, RelayHandle>} */
|
|
70
|
+
#operatorPeer = new Map();
|
|
71
|
+
/** peer -> its bridged operators, by the operator's fingerprint. @type {Map<RelayHandle, Map<string, RelayHandle>>} */
|
|
72
|
+
#peerOperators = new Map();
|
|
73
|
+
/** peers that stated `relay-multi-operator` in their last ReverseAccept. @type {WeakSet<RelayHandle>} */
|
|
74
|
+
#multiPeers = new WeakSet();
|
|
55
75
|
/** target handle -> { operator, timer } while a ReverseConnect awaits its answer */
|
|
56
76
|
#pending = new Map();
|
|
57
77
|
/** fingerprint -> the highest record seq ever accepted (survives disconnects, bounded). */
|
|
58
78
|
#lastSeq = new Map();
|
|
59
79
|
|
|
60
|
-
constructor({ canRegister, canConnect, maxPeers = DEFAULT_MAX_PEERS, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, log = () => {} } = {}) {
|
|
80
|
+
constructor({ canRegister, canConnect, maxPeers = DEFAULT_MAX_PEERS, maxOperatorsPerPeer = 1, connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MS, log = () => {} } = {}) {
|
|
61
81
|
for (const [name, fn] of [['canRegister', canRegister], ['canConnect', canConnect]]) {
|
|
62
82
|
if (fn !== undefined && typeof fn !== 'function') throw new TypeError(`createWshServer: relay.${name} must be a function`);
|
|
63
83
|
}
|
|
84
|
+
if (!Number.isSafeInteger(maxOperatorsPerPeer) || maxOperatorsPerPeer < 1) throw new TypeError('createWshServer: relay.maxOperatorsPerPeer must be a positive integer');
|
|
85
|
+
this.maxOperatorsPerPeer = maxOperatorsPerPeer;
|
|
64
86
|
this.canRegister = canRegister ?? (() => false);
|
|
65
87
|
this.canConnect = canConnect ?? (() => false);
|
|
66
88
|
this.maxPeers = maxPeers;
|
|
@@ -193,14 +215,19 @@ export class RelayHub {
|
|
|
193
215
|
fingerprint: target.meta.fingerprint, username: target.meta.username, capabilities: target.meta.capabilities,
|
|
194
216
|
}))) return reject('no such peer');
|
|
195
217
|
if (this.#peers.get(target.meta.fingerprint) !== target) return reject('no such peer');
|
|
196
|
-
// One bridge per peer
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
}
|
|
201
|
-
if (this.#pairs.has(operator) || [...this.#pending.values()].some((p) => p.operator === operator)) {
|
|
218
|
+
// One bridge per operator. A peer serves one operator unless the relay allows more AND the peer said it can
|
|
219
|
+
// address them (RelayForward.to_fingerprint) and be told when one leaves (ReverseClose); it also answers one
|
|
220
|
+
// request at a time, since a ReverseAccept does not name the operator it answers.
|
|
221
|
+
if (this.bridged(operator) || [...this.#pending.values()].some((p) => p.operator === operator)) {
|
|
202
222
|
return reject(BUSY_OPERATOR);
|
|
203
223
|
}
|
|
224
|
+
if (this.#pending.has(target.handle)) return reject(this.maxOperatorsPerPeer > 1 ? BUSY_ANSWERING : BUSY_PEER);
|
|
225
|
+
const serving = this.#peerOperators.get(target.handle);
|
|
226
|
+
if (serving?.size) {
|
|
227
|
+
if (this.maxOperatorsPerPeer < 2 || !this.#multiPeers.has(target.handle)) return reject(BUSY_PEER);
|
|
228
|
+
if (serving.has(operator.fingerprint)) return reject(BUSY_OPERATOR);
|
|
229
|
+
if (serving.size >= this.maxOperatorsPerPeer) return reject(busyPeerFull(this.maxOperatorsPerPeer));
|
|
230
|
+
}
|
|
204
231
|
const timer = setTimeout(() => {
|
|
205
232
|
if (this.#pending.get(target.handle)?.operator !== operator) return;
|
|
206
233
|
this.#pending.delete(target.handle);
|
|
@@ -232,26 +259,39 @@ export class RelayHub {
|
|
|
232
259
|
}
|
|
233
260
|
p.operator.send(answer);
|
|
234
261
|
if (msg.type === MSG.REVERSE_ACCEPT) {
|
|
235
|
-
this.#
|
|
236
|
-
this.#
|
|
262
|
+
if (Array.isArray(answer.features) && answer.features.includes(MULTI_OPERATOR_FEATURE)) this.#multiPeers.add(peer);
|
|
263
|
+
else this.#multiPeers.delete(peer);
|
|
264
|
+
this.#operatorPeer.set(p.operator, peer);
|
|
265
|
+
let ops = this.#peerOperators.get(peer);
|
|
266
|
+
if (!ops) this.#peerOperators.set(peer, ops = new Map());
|
|
267
|
+
ops.set(p.operator.fingerprint, p.operator);
|
|
237
268
|
this.log(`relay: bridged ${p.operator.fingerprint.slice(0, 12)} <-> ${peer.fingerprint.slice(0, 12)}`);
|
|
238
269
|
}
|
|
239
270
|
}
|
|
240
271
|
|
|
241
272
|
/** Is `handle` one end of a bridge? */
|
|
242
273
|
bridged(handle) {
|
|
243
|
-
return this.#
|
|
274
|
+
return this.#operatorPeer.has(handle) || (this.#peerOperators.get(handle)?.size ?? 0) > 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Fingerprints of the operators bridged to the peer with this fingerprint. */
|
|
278
|
+
operatorsOf(peerFingerprint) {
|
|
279
|
+
const peer = this.#peers.get(peerFingerprint)?.handle;
|
|
280
|
+
return peer ? [...(this.#peerOperators.get(peer)?.keys() ?? [])] : [];
|
|
244
281
|
}
|
|
245
282
|
|
|
246
283
|
/**
|
|
247
284
|
* Carry a message across `handle`'s bridge. A plain forwardable message is
|
|
248
285
|
* wrapped; a `RelayForward` the client wrote itself is unwrapped, its inner
|
|
249
286
|
* checked, and re-wrapped -- either way `from_fingerprint` is the sender's
|
|
250
|
-
* authenticated key.
|
|
287
|
+
* authenticated key. A peer serving several operators names the recipient
|
|
288
|
+
* with `to_fingerprint` (never set on what is delivered); with one operator
|
|
289
|
+
* it may leave it out. Returns false when there is no bridge.
|
|
251
290
|
*/
|
|
252
291
|
forward(handle, msg) {
|
|
253
|
-
const partner = this.#
|
|
254
|
-
if (
|
|
292
|
+
const partner = this.#recipient(handle, msg);
|
|
293
|
+
if (partner === undefined) return false;
|
|
294
|
+
if (partner === null) return true; // bridged, but the message could not be addressed: dropped (and logged)
|
|
255
295
|
let inner;
|
|
256
296
|
if (msg.type === MSG.RELAY_FORWARD) {
|
|
257
297
|
if (!(msg.inner instanceof Uint8Array) || msg.inner.byteLength > MAX_FORWARD_BYTES) { this.log('relay: dropped an oversized or malformed RelayForward'); return true; }
|
|
@@ -270,6 +310,27 @@ export class RelayHub {
|
|
|
270
310
|
return true;
|
|
271
311
|
}
|
|
272
312
|
|
|
313
|
+
/**
|
|
314
|
+
* Who `msg` from `handle` goes to: the partner handle; `undefined` if `handle` has no bridge (the caller
|
|
315
|
+
* treats the message as an ordinary one); `null` if it has one but the recipient cannot be determined.
|
|
316
|
+
*/
|
|
317
|
+
#recipient(handle, msg) {
|
|
318
|
+
const peer = this.#operatorPeer.get(handle);
|
|
319
|
+
if (peer) return peer; // an operator has exactly one bridge
|
|
320
|
+
const operators = this.#peerOperators.get(handle);
|
|
321
|
+
if (!operators || operators.size === 0) return undefined;
|
|
322
|
+
const to = msg.type === MSG.RELAY_FORWARD ? msg.to_fingerprint : undefined;
|
|
323
|
+
if (to !== undefined) {
|
|
324
|
+
const operator = typeof to === 'string' ? operators.get(to) : undefined;
|
|
325
|
+
if (operator) return operator;
|
|
326
|
+
this.log(`relay: dropped a forward from ${handle.fingerprint.slice(0, 12)} addressed to ${typeof to === 'string' ? to.slice(0, 12) : '?'}, which is not one of its operators`);
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
if (operators.size === 1) return operators.values().next().value;
|
|
330
|
+
this.log(`relay: dropped an unaddressed forward from ${handle.fingerprint.slice(0, 12)}, which serves ${operators.size} operators (to_fingerprint is required)`);
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
273
334
|
/** A connection ended: forget it, and end the bridge it was part of. */
|
|
274
335
|
drop(handle) {
|
|
275
336
|
const entry = this.#peers.get(handle.fingerprint);
|
|
@@ -279,11 +340,28 @@ export class RelayHub {
|
|
|
279
340
|
for (const [target, pend] of this.#pending) {
|
|
280
341
|
if (pend.operator === handle) { clearTimeout(pend.timer); this.#pending.delete(target); }
|
|
281
342
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
this.#
|
|
286
|
-
|
|
343
|
+
// An operator leaving.
|
|
344
|
+
const peer = this.#operatorPeer.get(handle);
|
|
345
|
+
if (peer) {
|
|
346
|
+
this.#operatorPeer.delete(handle);
|
|
347
|
+
const operators = this.#peerOperators.get(peer);
|
|
348
|
+
operators?.delete(handle.fingerprint);
|
|
349
|
+
if (this.maxOperatorsPerPeer > 1 && this.#multiPeers.has(peer)) {
|
|
350
|
+
// The peer serves others too (or may serve more): tell it which operator left instead of closing it.
|
|
351
|
+
peer.send(reverseClose({ targetFingerprint: handle.fingerprint, reason: 'operator left' }));
|
|
352
|
+
} else {
|
|
353
|
+
this.#peerOperators.delete(peer);
|
|
354
|
+
peer.close();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// A peer leaving: every operator bridged to it goes with it.
|
|
358
|
+
const operators = this.#peerOperators.get(handle);
|
|
359
|
+
if (operators) {
|
|
360
|
+
this.#peerOperators.delete(handle);
|
|
361
|
+
for (const operator of operators.values()) {
|
|
362
|
+
this.#operatorPeer.delete(operator);
|
|
363
|
+
operator.close();
|
|
364
|
+
}
|
|
287
365
|
}
|
|
288
366
|
}
|
|
289
367
|
|
|
@@ -291,7 +369,8 @@ export class RelayHub {
|
|
|
291
369
|
closeAll() {
|
|
292
370
|
for (const p of this.#pending.values()) clearTimeout(p.timer);
|
|
293
371
|
this.#pending.clear();
|
|
294
|
-
this.#
|
|
372
|
+
this.#operatorPeer.clear();
|
|
373
|
+
this.#peerOperators.clear();
|
|
295
374
|
this.#peers.clear();
|
|
296
375
|
}
|
|
297
376
|
}
|