@johnhenry/wsh 0.7.0 → 0.13.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/package.json +4 -1
- package/src/auth.mjs +106 -0
- package/src/client.mjs +491 -174
- package/src/file-transfer.mjs +39 -166
- package/src/index.d.ts +110 -0
- package/src/index.mjs +13 -0
- package/src/messages.gen.mjs +66 -10
- package/src/messages.mjs +1 -1
- package/src/mlkem.mjs +144 -0
- package/src/qmux-connection.mjs +698 -0
- package/src/qmux.mjs +620 -0
- package/src/session.mjs +62 -0
- package/src/transport-ws.mjs +145 -352
package/src/client.mjs
CHANGED
|
@@ -28,10 +28,17 @@ import {
|
|
|
28
28
|
copilotDetach as copilotDetachMsg,
|
|
29
29
|
keyExchange as keyExchangeMsg,
|
|
30
30
|
fileOp as fileOpMsg,
|
|
31
|
+
fileChunk as fileChunkMsg,
|
|
31
32
|
policyEval as policyEvalMsg, policyUpdate as policyUpdateMsg,
|
|
33
|
+
detach as detachMsg,
|
|
34
|
+
sessionListRequest as sessionListRequestMsg,
|
|
35
|
+
sessionGrant as sessionGrantMsg, sessionRevoke as sessionRevokeMsg,
|
|
36
|
+
isRelayForwardable,
|
|
32
37
|
} from './messages.mjs';
|
|
33
|
-
import { signChallenge, exportPublicKeyRaw } from './auth.mjs';
|
|
38
|
+
import { signChallenge, exportPublicKeyRaw, signPeerRecord, verifyPeerRecord, importPublicKeyRaw, fingerprint as computeFingerprint } from './auth.mjs';
|
|
39
|
+
import { generateMlKemKeyPair, mlKemEncapsulate, mlKemDecapsulate } from './mlkem.mjs';
|
|
34
40
|
import { WshSession } from './session.mjs';
|
|
41
|
+
import { cborDecode } from './cbor.mjs';
|
|
35
42
|
|
|
36
43
|
// ── Client states ─────────────────────────────────────────────────────
|
|
37
44
|
|
|
@@ -49,71 +56,72 @@ const DEFAULT_PING_INTERVAL = 30_000; // ms
|
|
|
49
56
|
const DEFAULT_EXEC_TIMEOUT = 60_000; // ms
|
|
50
57
|
const FILE_CHUNK_SIZE = 65_536;
|
|
51
58
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Verify a `PeerInfo` entry's self-signed record (see `listPeers`).
|
|
61
|
+
* Defensive by design: any missing field, malformed key, or fingerprint
|
|
62
|
+
* mismatch resolves to `false` rather than throwing, so one bad entry
|
|
63
|
+
* from an untrusted relay can't break the whole `listPeers()` call.
|
|
64
|
+
* @param {object} peer - a raw `PeerInfo` wire entry
|
|
65
|
+
* @returns {Promise<boolean>}
|
|
66
|
+
*/
|
|
67
|
+
async function verifyPeerInfoRecord(peer) {
|
|
68
|
+
if (!peer.public_key || !peer.record_signature || peer.seq === undefined || peer.seq === null) return false;
|
|
69
|
+
try {
|
|
70
|
+
const claimedFingerprint = await computeFingerprint(peer.public_key);
|
|
71
|
+
if (claimedFingerprint !== peer.fingerprint) return false;
|
|
72
|
+
const publicKey = await importPublicKeyRaw(peer.public_key);
|
|
73
|
+
return await verifyPeerRecord(publicKey, peer.record_signature, {
|
|
74
|
+
username: peer.username,
|
|
75
|
+
peerType: peer.peer_type,
|
|
76
|
+
shellBackend: peer.shell_backend,
|
|
77
|
+
capabilities: peer.capabilities,
|
|
78
|
+
supportsAttach: peer.supports_attach,
|
|
79
|
+
supportsReplay: peer.supports_replay,
|
|
80
|
+
supportsEcho: peer.supports_echo,
|
|
81
|
+
supportsTermSync: peer.supports_term_sync,
|
|
82
|
+
seq: peer.seq,
|
|
83
|
+
});
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
77
87
|
}
|
|
78
88
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Lexicographic byte comparison, used by `initiateE2E`'s hybrid mode to
|
|
91
|
+
* deterministically assign the ML-KEM "encapsulator" role without an
|
|
92
|
+
* extra round trip: both sides already have both ephemeral X25519
|
|
93
|
+
* public keys after round 1, so whichever side's own key sorts lower
|
|
94
|
+
* encapsulates.
|
|
95
|
+
* @returns {number} <0 if a<b, >0 if a>b, 0 if equal
|
|
96
|
+
*/
|
|
97
|
+
function compareBytes(a, b) {
|
|
98
|
+
const len = Math.min(a.length, b.length);
|
|
99
|
+
for (let i = 0; i < len; i++) {
|
|
100
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
101
|
+
}
|
|
102
|
+
return a.length - b.length;
|
|
82
103
|
}
|
|
83
104
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return output;
|
|
107
|
-
},
|
|
108
|
-
async readChunk() {
|
|
109
|
-
if (remainder.byteLength > 0) {
|
|
110
|
-
const chunk = remainder;
|
|
111
|
-
remainder = new Uint8Array();
|
|
112
|
-
return chunk;
|
|
113
|
-
}
|
|
114
|
-
return await session.read();
|
|
115
|
-
},
|
|
116
|
-
};
|
|
105
|
+
/**
|
|
106
|
+
* Combine the classical (X25519 ECDH) and post-quantum (ML-KEM-768) key
|
|
107
|
+
* exchange outputs into one AES-256-GCM key via HKDF-SHA256, so the
|
|
108
|
+
* final key is only as weak as the *stronger* of the two if either
|
|
109
|
+
* primitive is ever broken.
|
|
110
|
+
* @param {Uint8Array} x25519Bits - 32-byte ECDH shared secret
|
|
111
|
+
* @param {Uint8Array} kemSharedSecret - 32-byte ML-KEM-768 shared secret
|
|
112
|
+
* @returns {Promise<Uint8Array>} 32 bytes of combined key material
|
|
113
|
+
*/
|
|
114
|
+
async function combineHybridSecret(x25519Bits, kemSharedSecret) {
|
|
115
|
+
const ikm = new Uint8Array(x25519Bits.length + kemSharedSecret.length);
|
|
116
|
+
ikm.set(x25519Bits, 0);
|
|
117
|
+
ikm.set(kemSharedSecret, x25519Bits.length);
|
|
118
|
+
const hkdfKey = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
|
119
|
+
const bits = await crypto.subtle.deriveBits(
|
|
120
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: new TextEncoder().encode('wsh-hybrid-e2e-v1') },
|
|
121
|
+
hkdfKey,
|
|
122
|
+
256
|
|
123
|
+
);
|
|
124
|
+
return new Uint8Array(bits);
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
// ── Client class ──────────────────────────────────────────────────────
|
|
@@ -138,6 +146,31 @@ export class WshClient {
|
|
|
138
146
|
/** @type {Map<number, WshSession>} Active sessions keyed by channel ID. */
|
|
139
147
|
#sessions = new Map();
|
|
140
148
|
|
|
149
|
+
/**
|
|
150
|
+
* FIFO queue of in-flight openSession() calls awaiting the next
|
|
151
|
+
* OPEN_OK/OPEN_FAIL. Handled as a dedicated synchronous case in
|
|
152
|
+
* #handleControl (not the generic #waitForMessage machinery) so that
|
|
153
|
+
* #sessions.set() happens in the same synchronous dispatch step as
|
|
154
|
+
* OPEN_OK itself, with no microtask hop in between. That matters
|
|
155
|
+
* because a server can legitimately push channel-scoped data (e.g. the
|
|
156
|
+
* first FileChunk of a download) immediately after OPEN_OK, landing in
|
|
157
|
+
* the same message batch -- dispatchSerially only guarantees one
|
|
158
|
+
* microtask tick of separation between batch items, and registering the
|
|
159
|
+
* session via a promise continuation takes more hops than that, so the
|
|
160
|
+
* next item in the batch could still find #sessions.has(channelId)
|
|
161
|
+
* false and be misrouted/dropped.
|
|
162
|
+
* @type {Array<{kind: string, resolve: function, reject: function, timer: number}>}
|
|
163
|
+
*/
|
|
164
|
+
#pendingOpens = [];
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Fingerprints of reverse-connect peers this client has accepted a relay
|
|
168
|
+
* bridge with. RelayForward-wrapped messages are only unwrapped and
|
|
169
|
+
* delivered if their from_fingerprint is in this set — see trustRelayPeer.
|
|
170
|
+
* @type {Set<string>}
|
|
171
|
+
*/
|
|
172
|
+
#acceptedRelayPeers = new Set();
|
|
173
|
+
|
|
141
174
|
/** @type {number} Monotonically increasing channel ID counter. */
|
|
142
175
|
#channelCounter = 0;
|
|
143
176
|
|
|
@@ -465,42 +498,25 @@ export class WshClient {
|
|
|
465
498
|
openMsg({ kind: type, command, cols, rows, env })
|
|
466
499
|
);
|
|
467
500
|
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
serverChannelId,
|
|
488
|
-
streamIds,
|
|
489
|
-
type,
|
|
490
|
-
{ dataMode, capabilities }
|
|
491
|
-
);
|
|
492
|
-
this.#sessions.set(serverChannelId, session);
|
|
493
|
-
|
|
494
|
-
if (dataMode === 'virtual') {
|
|
495
|
-
session._activateVirtual((msg) => this.sendRelayControl(msg));
|
|
496
|
-
return session;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// Open the data stream and bind it to the session.
|
|
500
|
-
const stream = await this.#transport.openStream();
|
|
501
|
-
session._bind(stream.readable, stream.writable);
|
|
502
|
-
|
|
503
|
-
return session;
|
|
501
|
+
// Registered as a dedicated pending-open (not the generic
|
|
502
|
+
// #waitForMessage waiter) so #handleControl can construct and
|
|
503
|
+
// register the WshSession synchronously the instant OPEN_OK arrives
|
|
504
|
+
// — see #pendingOpens's doc comment for why that matters.
|
|
505
|
+
return new Promise((resolve, reject) => {
|
|
506
|
+
const entry = {
|
|
507
|
+
mode: 'session',
|
|
508
|
+
kind: type,
|
|
509
|
+
requestedChannelId,
|
|
510
|
+
resolve,
|
|
511
|
+
reject,
|
|
512
|
+
};
|
|
513
|
+
entry.timer = setTimeout(() => {
|
|
514
|
+
const idx = this.#pendingOpens.indexOf(entry);
|
|
515
|
+
if (idx !== -1) this.#pendingOpens.splice(idx, 1);
|
|
516
|
+
reject(new Error('Timed out waiting for session open response'));
|
|
517
|
+
}, timeout);
|
|
518
|
+
this.#pendingOpens.push(entry);
|
|
519
|
+
});
|
|
504
520
|
}
|
|
505
521
|
|
|
506
522
|
/**
|
|
@@ -531,16 +547,25 @@ export class WshClient {
|
|
|
531
547
|
async attachSession(targetSessionId, { readOnly = false, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
|
|
532
548
|
this.#assertAuthenticated('attachSession');
|
|
533
549
|
|
|
534
|
-
const
|
|
550
|
+
const attachMode = readOnly ? 'readonly' : 'control';
|
|
535
551
|
await this.#transport.sendControl(
|
|
536
|
-
attachMsg({ sessionId: targetSessionId, token: this.#resumeToken, mode })
|
|
552
|
+
attachMsg({ sessionId: targetSessionId, token: this.#resumeToken, mode: attachMode })
|
|
537
553
|
);
|
|
538
554
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
555
|
+
// Shares #pendingOpens with openSession() -- both wait on the same
|
|
556
|
+
// OPEN_OK/OPEN_FAIL response stream, in the order their requests were
|
|
557
|
+
// sent (see #pendingOpens's doc comment). Unlike openSession(),
|
|
558
|
+
// attachSession() doesn't construct a WshSession, so it just wants
|
|
559
|
+
// the raw response either way.
|
|
560
|
+
const response = await new Promise((resolve, reject) => {
|
|
561
|
+
const entry = { mode: 'raw', resolve, reject };
|
|
562
|
+
entry.timer = setTimeout(() => {
|
|
563
|
+
const idx = this.#pendingOpens.indexOf(entry);
|
|
564
|
+
if (idx !== -1) this.#pendingOpens.splice(idx, 1);
|
|
565
|
+
reject(new Error('Timed out waiting for attach response'));
|
|
566
|
+
}, timeout);
|
|
567
|
+
this.#pendingOpens.push(entry);
|
|
568
|
+
});
|
|
544
569
|
|
|
545
570
|
if (response.type === MSG.OPEN_FAIL) {
|
|
546
571
|
throw new Error(`Failed to attach: ${response.reason || 'rejected'}`);
|
|
@@ -577,6 +602,86 @@ export class WshClient {
|
|
|
577
602
|
return response;
|
|
578
603
|
}
|
|
579
604
|
|
|
605
|
+
/**
|
|
606
|
+
* Detach from a remote session: release control (stop receiving its
|
|
607
|
+
* output) while leaving it running server-side, so it can be resumed
|
|
608
|
+
* later via resumeSession(). Mirrors the Rust client/CLI's `wsh detach`.
|
|
609
|
+
*
|
|
610
|
+
* @param {string} sessionId - Remote session ID to detach from
|
|
611
|
+
* @param {number} [timeout=10000]
|
|
612
|
+
*/
|
|
613
|
+
async detach(sessionId, timeout = DEFAULT_OPEN_TIMEOUT) {
|
|
614
|
+
this.#assertAuthenticated('detach');
|
|
615
|
+
|
|
616
|
+
await this.#transport.sendControl(detachMsg({ sessionId }));
|
|
617
|
+
|
|
618
|
+
const response = await this.#waitForMessage(
|
|
619
|
+
[MSG.DETACH_OK, MSG.DETACH_FAIL],
|
|
620
|
+
timeout,
|
|
621
|
+
'Timed out waiting for detach response'
|
|
622
|
+
);
|
|
623
|
+
|
|
624
|
+
if (response.type === MSG.DETACH_FAIL) {
|
|
625
|
+
throw new Error(`Failed to detach: ${response.reason || 'rejected'}`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* List sessions on the server that this connection's key owns or has
|
|
631
|
+
* been granted access to (a server round trip — distinct from the
|
|
632
|
+
* purely local listSessions(), which only reports channels open on
|
|
633
|
+
* this connection). Mirrors the Rust client's list_remote_sessions().
|
|
634
|
+
*
|
|
635
|
+
* @param {number} [timeout=10000]
|
|
636
|
+
* @returns {Promise<Array<object>>} Server-reported session summaries
|
|
637
|
+
*/
|
|
638
|
+
async listRemoteSessions(timeout = DEFAULT_OPEN_TIMEOUT) {
|
|
639
|
+
this.#assertAuthenticated('listRemoteSessions');
|
|
640
|
+
|
|
641
|
+
await this.#transport.sendControl(sessionListRequestMsg());
|
|
642
|
+
|
|
643
|
+
const response = await this.#waitForMessage(
|
|
644
|
+
[MSG.SESSION_LIST],
|
|
645
|
+
timeout,
|
|
646
|
+
'Timed out waiting for session list response'
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
return response.sessions || [];
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Grant another authenticated principal (username or fingerprint)
|
|
654
|
+
* access to a session this connection owns. Server-enforced (only the
|
|
655
|
+
* session owner may grant); fire-and-forget like updatePolicy() — the
|
|
656
|
+
* server sends no response on success, only an Error envelope on
|
|
657
|
+
* rejection (surfaced via onError, not this call).
|
|
658
|
+
*
|
|
659
|
+
* @param {string} sessionId
|
|
660
|
+
* @param {string} principal - Username or fingerprint of the grantee
|
|
661
|
+
* @param {string[]} [permissions=['read']]
|
|
662
|
+
*/
|
|
663
|
+
async grantSessionAccess(sessionId, principal, permissions = ['read']) {
|
|
664
|
+
this.#assertAuthenticated('grantSessionAccess');
|
|
665
|
+
await this.#transport.sendControl(
|
|
666
|
+
sessionGrantMsg({ sessionId, principal, permissions })
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Revoke a previously granted principal's access to a session this
|
|
672
|
+
* connection owns. Same fire-and-forget shape as grantSessionAccess().
|
|
673
|
+
*
|
|
674
|
+
* @param {string} sessionId
|
|
675
|
+
* @param {string} principal
|
|
676
|
+
* @param {string} [reason]
|
|
677
|
+
*/
|
|
678
|
+
async revokeSessionAccess(sessionId, principal, reason) {
|
|
679
|
+
this.#assertAuthenticated('revokeSessionAccess');
|
|
680
|
+
await this.#transport.sendControl(
|
|
681
|
+
sessionRevokeMsg({ sessionId, principal, reason })
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
580
685
|
// ── Disconnect ──────────────────────────────────────────────────────
|
|
581
686
|
|
|
582
687
|
/**
|
|
@@ -714,19 +819,37 @@ export class WshClient {
|
|
|
714
819
|
}
|
|
715
820
|
|
|
716
821
|
const effectiveShellBackend = shellBackend || (expose.shell ? 'virtual-shell' : 'exec-only');
|
|
822
|
+
const record = {
|
|
823
|
+
username,
|
|
824
|
+
capabilities,
|
|
825
|
+
peerType,
|
|
826
|
+
shellBackend: effectiveShellBackend,
|
|
827
|
+
supportsAttach: supportsAttach ?? effectiveShellBackend !== 'exec-only',
|
|
828
|
+
supportsReplay: supportsReplay ?? effectiveShellBackend !== 'exec-only',
|
|
829
|
+
supportsEcho: supportsEcho ?? effectiveShellBackend === 'virtual-shell',
|
|
830
|
+
supportsTermSync: supportsTermSync ?? effectiveShellBackend === 'virtual-shell',
|
|
831
|
+
// The peer's own monotonic counter for this signed record --
|
|
832
|
+
// current-time-millis in practice (see buildPeerRecordTranscript).
|
|
833
|
+
// A server/operator must reject a record whose seq doesn't exceed
|
|
834
|
+
// the last one accepted for this fingerprint.
|
|
835
|
+
seq: Date.now(),
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
// Self-sign the registration so ReversePeers entries built from it
|
|
839
|
+
// are verifiable by an operator independent of trusting the relay
|
|
840
|
+
// (see auth.mjs's "Signed peer records" section). Only possible with
|
|
841
|
+
// a real identity key, same precondition `publicKey` already had.
|
|
842
|
+
let recordSignature;
|
|
843
|
+
if (keyPair) {
|
|
844
|
+
({ signature: recordSignature } = await signPeerRecord(keyPair.privateKey, keyPair.publicKey, record));
|
|
845
|
+
}
|
|
717
846
|
|
|
718
847
|
// Register as a reverse peer.
|
|
719
848
|
await this.#transport.sendControl(
|
|
720
849
|
reverseRegisterMsg({
|
|
721
|
-
|
|
722
|
-
capabilities,
|
|
723
|
-
peerType,
|
|
724
|
-
shellBackend: effectiveShellBackend,
|
|
725
|
-
supportsAttach: supportsAttach ?? effectiveShellBackend !== 'exec-only',
|
|
726
|
-
supportsReplay: supportsReplay ?? effectiveShellBackend !== 'exec-only',
|
|
727
|
-
supportsEcho: supportsEcho ?? effectiveShellBackend === 'virtual-shell',
|
|
728
|
-
supportsTermSync: supportsTermSync ?? effectiveShellBackend === 'virtual-shell',
|
|
850
|
+
...record,
|
|
729
851
|
publicKey,
|
|
852
|
+
recordSignature,
|
|
730
853
|
})
|
|
731
854
|
);
|
|
732
855
|
|
|
@@ -736,8 +859,19 @@ export class WshClient {
|
|
|
736
859
|
/**
|
|
737
860
|
* List peers registered on the relay server.
|
|
738
861
|
*
|
|
862
|
+
* Each entry's `verified` field is computed here, client-side, from
|
|
863
|
+
* the peer's own signed record (`public_key`/`seq`/`record_signature`,
|
|
864
|
+
* present when the relay honestly forwards what the peer sent) --
|
|
865
|
+
* `true` only if the signature actually verifies against `public_key`
|
|
866
|
+
* AND that key's fingerprint matches the claimed `fingerprint` field.
|
|
867
|
+
* This is deliberately NOT trust-on-first-use of the relay's own
|
|
868
|
+
* claims: a relay that omits or tampers with these fields, or that
|
|
869
|
+
* substitutes a different key, produces `verified: false` rather than
|
|
870
|
+
* silently passing. `verified` is additive to the wire response, not
|
|
871
|
+
* a wire field itself.
|
|
872
|
+
*
|
|
739
873
|
* @param {number} [timeout=10000] - Timeout in ms
|
|
740
|
-
* @returns {Promise<Array<{fingerprint_short: string, username: string, capabilities: string[], last_seen: number|null}>>}
|
|
874
|
+
* @returns {Promise<Array<{fingerprint_short: string, username: string, capabilities: string[], last_seen: number|null, verified: boolean}>>}
|
|
741
875
|
*/
|
|
742
876
|
async listPeers(timeout = DEFAULT_OPEN_TIMEOUT) {
|
|
743
877
|
this.#assertAuthenticated('listPeers');
|
|
@@ -750,7 +884,8 @@ export class WshClient {
|
|
|
750
884
|
'Timed out waiting for peer list'
|
|
751
885
|
);
|
|
752
886
|
|
|
753
|
-
|
|
887
|
+
const peers = response.peers || [];
|
|
888
|
+
return Promise.all(peers.map(async (peer) => ({ ...peer, verified: await verifyPeerInfoRecord(peer) })));
|
|
754
889
|
}
|
|
755
890
|
|
|
756
891
|
/**
|
|
@@ -778,14 +913,22 @@ export class WshClient {
|
|
|
778
913
|
this.#assertAuthenticated('reverseConnect');
|
|
779
914
|
|
|
780
915
|
await this.#transport.sendControl(
|
|
781
|
-
|
|
916
|
+
// fromFingerprint is ignored by the server -- it overwrites it with
|
|
917
|
+
// this connection's own authenticated fingerprint before forwarding.
|
|
918
|
+
reverseConnectMsg({ targetFingerprint, username: '', fromFingerprint: '' })
|
|
782
919
|
);
|
|
783
920
|
|
|
784
|
-
|
|
921
|
+
const response = await this.#waitForMessage(
|
|
785
922
|
[MSG.REVERSE_ACCEPT, MSG.REVERSE_REJECT],
|
|
786
923
|
timeout,
|
|
787
924
|
'Timed out waiting for reverse-connect response'
|
|
788
925
|
);
|
|
926
|
+
|
|
927
|
+
if (response.type === MSG.REVERSE_ACCEPT) {
|
|
928
|
+
this.trustRelayPeer(response.target_fingerprint);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
return response;
|
|
789
932
|
}
|
|
790
933
|
|
|
791
934
|
/**
|
|
@@ -802,12 +945,40 @@ export class WshClient {
|
|
|
802
945
|
await this.#transport.sendControl(msg);
|
|
803
946
|
}
|
|
804
947
|
|
|
948
|
+
/**
|
|
949
|
+
* Mark a peer fingerprint as an accepted reverse-connect bridge partner.
|
|
950
|
+
*
|
|
951
|
+
* Call this once a ReverseConnect has been accepted (either side): the
|
|
952
|
+
* target after sending ReverseAccept in response to an incoming request
|
|
953
|
+
* (using the request's from_fingerprint), or the operator after receiving
|
|
954
|
+
* ReverseAccept for a request it sent (handled automatically by
|
|
955
|
+
* reverseConnect()). Only RelayForward-wrapped messages whose
|
|
956
|
+
* from_fingerprint is trusted this way are unwrapped and delivered.
|
|
957
|
+
*
|
|
958
|
+
* @param {string} fingerprint
|
|
959
|
+
*/
|
|
960
|
+
trustRelayPeer(fingerprint) {
|
|
961
|
+
this.#acceptedRelayPeers.add(fingerprint);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Stop trusting a peer as a relay-forward bridge partner (e.g. on session end).
|
|
966
|
+
* @param {string} fingerprint
|
|
967
|
+
*/
|
|
968
|
+
untrustRelayPeer(fingerprint) {
|
|
969
|
+
this.#acceptedRelayPeers.delete(fingerprint);
|
|
970
|
+
}
|
|
971
|
+
|
|
805
972
|
// ── File transfer ───────────────────────────────────────────────────
|
|
806
973
|
|
|
807
974
|
/**
|
|
808
975
|
* Upload a blob to a remote path.
|
|
809
976
|
*
|
|
810
|
-
* Opens a file channel,
|
|
977
|
+
* Opens a file channel, then sends the data as a sequence of FileChunk
|
|
978
|
+
* control messages (offset-addressed, the last one marked is_final) and
|
|
979
|
+
* waits for the server to confirm completion. Works the same whether the
|
|
980
|
+
* channel's data plane is stream- or virtual-backed, since FileChunk is
|
|
981
|
+
* an ordinary control message rather than raw stream bytes.
|
|
811
982
|
*
|
|
812
983
|
* @param {Blob|Uint8Array} blob - Data to upload
|
|
813
984
|
* @param {string} remotePath - Destination path on the server
|
|
@@ -821,21 +992,31 @@ export class WshClient {
|
|
|
821
992
|
? new Uint8Array(await blob.arrayBuffer())
|
|
822
993
|
: blob;
|
|
823
994
|
const total = data.byteLength;
|
|
824
|
-
const header = buildUploadHeader(remotePath, total);
|
|
825
|
-
let sent = 0;
|
|
826
995
|
|
|
827
996
|
try {
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
const end = Math.min(
|
|
831
|
-
await
|
|
997
|
+
let sent = 0;
|
|
998
|
+
do {
|
|
999
|
+
const end = Math.min(sent + FILE_CHUNK_SIZE, total);
|
|
1000
|
+
await this.#transport.sendControl(fileChunkMsg({
|
|
1001
|
+
channelId: session.channelId,
|
|
1002
|
+
offset: sent,
|
|
1003
|
+
data: data.subarray(sent, end),
|
|
1004
|
+
isFinal: end >= total,
|
|
1005
|
+
totalSize: total,
|
|
1006
|
+
}));
|
|
832
1007
|
sent = end;
|
|
833
1008
|
onProgress?.(sent);
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1009
|
+
} while (sent < total);
|
|
1010
|
+
|
|
1011
|
+
// The server confirms completion the same way an exec session
|
|
1012
|
+
// signals it finished: Exit with a code, then Close. Resolve on
|
|
1013
|
+
// whichever arrives first.
|
|
1014
|
+
const code = await new Promise((resolve) => {
|
|
1015
|
+
session.onExit = (c) => { session.onExit = null; resolve(c); };
|
|
1016
|
+
session.onClose = () => resolve(null);
|
|
1017
|
+
});
|
|
1018
|
+
if (code !== 0) {
|
|
1019
|
+
throw new Error(`Upload failed with exit code ${code ?? 'unknown'}`);
|
|
839
1020
|
}
|
|
840
1021
|
} finally {
|
|
841
1022
|
await session.close().catch(() => {});
|
|
@@ -845,36 +1026,45 @@ export class WshClient {
|
|
|
845
1026
|
/**
|
|
846
1027
|
* Download a file from a remote path.
|
|
847
1028
|
*
|
|
1029
|
+
* Opens a file channel and reads the file as a sequence of FileChunk
|
|
1030
|
+
* control messages until the final chunk arrives. offset/total_size are
|
|
1031
|
+
* checked so a truncated transfer (channel closes before is_final, or
|
|
1032
|
+
* the final chunk doesn't actually reach total_size) is detected rather
|
|
1033
|
+
* than silently returned as a short file.
|
|
1034
|
+
*
|
|
848
1035
|
* @param {string} remotePath - Source path on the server
|
|
1036
|
+
* @param {object} [opts]
|
|
1037
|
+
* @param {function({received: number, total: number}): void} [opts.onProgress]
|
|
1038
|
+
* @param {number} [opts.timeout=10000] - Timeout in ms waiting for the channel to open
|
|
849
1039
|
* @returns {Promise<Uint8Array>} File contents
|
|
850
1040
|
*/
|
|
851
|
-
async download(remotePath) {
|
|
1041
|
+
async download(remotePath, { onProgress, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
|
|
852
1042
|
this.#assertAuthenticated('download');
|
|
853
|
-
const session = await this.openSession({ type: 'file', command: `download:${remotePath}
|
|
1043
|
+
const session = await this.openSession({ type: 'file', command: `download:${remotePath}`, timeout });
|
|
854
1044
|
|
|
855
1045
|
try {
|
|
856
|
-
|
|
857
|
-
|
|
1046
|
+
let data = null;
|
|
1047
|
+
let received = 0;
|
|
858
1048
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
throw new Error('Download failed: unexpected EOF reading file size');
|
|
862
|
-
}
|
|
863
|
-
const totalSize = decodeU64(sizeChunk);
|
|
864
|
-
const data = new Uint8Array(totalSize);
|
|
865
|
-
let offset = 0;
|
|
866
|
-
|
|
867
|
-
while (offset < totalSize) {
|
|
868
|
-
const chunk = await reader.readChunk();
|
|
1049
|
+
while (true) {
|
|
1050
|
+
const chunk = await session._readFileChunk();
|
|
869
1051
|
if (chunk === null) {
|
|
870
|
-
throw new Error('Download failed:
|
|
1052
|
+
throw new Error('Download failed: connection closed before the transfer completed (truncated)');
|
|
871
1053
|
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1054
|
+
if (data === null) {
|
|
1055
|
+
data = new Uint8Array(chunk.total_size ?? 0);
|
|
1056
|
+
}
|
|
1057
|
+
data.set(chunk.data, chunk.offset);
|
|
1058
|
+
received = chunk.offset + chunk.data.byteLength;
|
|
1059
|
+
onProgress?.({ received, total: data.byteLength });
|
|
876
1060
|
|
|
877
|
-
|
|
1061
|
+
if (chunk.is_final) {
|
|
1062
|
+
if (received !== data.byteLength) {
|
|
1063
|
+
throw new Error(`Download failed: truncated transfer (received ${received} of ${data.byteLength} bytes)`);
|
|
1064
|
+
}
|
|
1065
|
+
return data;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
878
1068
|
} finally {
|
|
879
1069
|
await session.close().catch(() => {});
|
|
880
1070
|
}
|
|
@@ -1141,28 +1331,50 @@ export class WshClient {
|
|
|
1141
1331
|
// ── E2E Encryption ────────────────────────────────────────────────
|
|
1142
1332
|
|
|
1143
1333
|
/**
|
|
1144
|
-
* Initiate end-to-end encryption for a session
|
|
1334
|
+
* Initiate end-to-end encryption for a session.
|
|
1335
|
+
*
|
|
1336
|
+
* `algorithm: 'X25519'` (default): classical ECDH only, one round trip
|
|
1337
|
+
* -- both sides send their ephemeral public key, derive the shared
|
|
1338
|
+
* secret directly.
|
|
1339
|
+
*
|
|
1340
|
+
* `algorithm: 'X25519+ML-KEM-768'`: hybrid classical+post-quantum. Round
|
|
1341
|
+
* 1 is the same as classical, plus both sides also send a fresh
|
|
1342
|
+
* ML-KEM-768 public key. Each side then deterministically derives the
|
|
1343
|
+
* same "encapsulator"/"decapsulator" role assignment by comparing the
|
|
1344
|
+
* two exchanged X25519 public keys byte-lexicographically (no extra
|
|
1345
|
+
* round trip needed, since both sides already have both values after
|
|
1346
|
+
* round 1) -- the encapsulator encapsulates against the decapsulator's
|
|
1347
|
+
* ML-KEM-768 key and sends the ciphertext in a second KeyExchange
|
|
1348
|
+
* message; the decapsulator decapsulates it. Both combine the X25519
|
|
1349
|
+
* and ML-KEM-768 outputs via HKDF-SHA256. Falls back to classical
|
|
1350
|
+
* automatically if the peer's round-1 message doesn't include a
|
|
1351
|
+
* kem_public_key (it doesn't support hybrid mode) -- algorithm
|
|
1352
|
+
* agility, not a hard cutover; check the returned `hybrid` flag to see
|
|
1353
|
+
* which actually happened.
|
|
1354
|
+
*
|
|
1145
1355
|
* @param {string} sessionId - Session ID
|
|
1146
|
-
* @param {string} [algorithm='X25519'] -
|
|
1356
|
+
* @param {string} [algorithm='X25519'] - 'X25519' or 'X25519+ML-KEM-768'
|
|
1147
1357
|
* @param {number} [timeout=10000]
|
|
1148
|
-
* @returns {Promise<{sharedSecret: CryptoKey, peerPublicKey: Uint8Array}>}
|
|
1358
|
+
* @returns {Promise<{sharedSecret: CryptoKey, peerPublicKey: Uint8Array, hybrid: boolean}>}
|
|
1149
1359
|
*/
|
|
1150
1360
|
async initiateE2E(sessionId, algorithm = 'X25519', timeout = DEFAULT_OPEN_TIMEOUT) {
|
|
1151
1361
|
this.#assertAuthenticated('initiateE2E');
|
|
1362
|
+
const wantHybrid = algorithm === 'X25519+ML-KEM-768';
|
|
1152
1363
|
|
|
1153
|
-
// Generate ephemeral X25519 key pair
|
|
1364
|
+
// Generate ephemeral X25519 key pair (and, for hybrid, a fresh
|
|
1365
|
+
// ML-KEM-768 key pair too).
|
|
1154
1366
|
const ephemeral = await crypto.subtle.generateKey(
|
|
1155
1367
|
{ name: 'X25519' },
|
|
1156
1368
|
false,
|
|
1157
1369
|
['deriveBits']
|
|
1158
1370
|
);
|
|
1159
|
-
|
|
1160
1371
|
const localPub = new Uint8Array(
|
|
1161
1372
|
await crypto.subtle.exportKey('raw', ephemeral.publicKey)
|
|
1162
1373
|
);
|
|
1374
|
+
const localKem = wantHybrid ? await generateMlKemKeyPair() : null;
|
|
1163
1375
|
|
|
1164
1376
|
await this.#transport.sendControl(
|
|
1165
|
-
keyExchangeMsg({ algorithm, publicKey: localPub, sessionId })
|
|
1377
|
+
keyExchangeMsg({ algorithm, publicKey: localPub, sessionId, kemPublicKey: localKem?.publicKey })
|
|
1166
1378
|
);
|
|
1167
1379
|
|
|
1168
1380
|
const peerMsg = await this.#waitForMessage(
|
|
@@ -1171,7 +1383,7 @@ export class WshClient {
|
|
|
1171
1383
|
'Timed out waiting for peer key exchange'
|
|
1172
1384
|
);
|
|
1173
1385
|
|
|
1174
|
-
// Import peer's public key and derive shared secret
|
|
1386
|
+
// Import peer's public key and derive the classical shared secret.
|
|
1175
1387
|
const peerKey = await crypto.subtle.importKey(
|
|
1176
1388
|
'raw',
|
|
1177
1389
|
peerMsg.public_key,
|
|
@@ -1179,23 +1391,46 @@ export class WshClient {
|
|
|
1179
1391
|
false,
|
|
1180
1392
|
[]
|
|
1181
1393
|
);
|
|
1182
|
-
|
|
1183
|
-
const sharedBits = await crypto.subtle.deriveBits(
|
|
1394
|
+
const sharedBits = new Uint8Array(await crypto.subtle.deriveBits(
|
|
1184
1395
|
{ name: 'X25519', public: peerKey },
|
|
1185
1396
|
ephemeral.privateKey,
|
|
1186
1397
|
256
|
|
1187
|
-
);
|
|
1398
|
+
));
|
|
1399
|
+
|
|
1400
|
+
const hybridActive = wantHybrid && !!localKem && !!peerMsg.kem_public_key;
|
|
1401
|
+
let combinedBits = sharedBits;
|
|
1402
|
+
|
|
1403
|
+
if (hybridActive) {
|
|
1404
|
+
const peerKemPublicKey = new Uint8Array(peerMsg.kem_public_key);
|
|
1405
|
+
const isEncapsulator = compareBytes(localPub, new Uint8Array(peerMsg.public_key)) < 0;
|
|
1406
|
+
|
|
1407
|
+
let kemSharedSecret;
|
|
1408
|
+
if (isEncapsulator) {
|
|
1409
|
+
const { ciphertext, sharedSecret } = await mlKemEncapsulate(peerKemPublicKey);
|
|
1410
|
+
kemSharedSecret = sharedSecret;
|
|
1411
|
+
await this.#transport.sendControl(keyExchangeMsg({ algorithm, sessionId, kemCiphertext: ciphertext }));
|
|
1412
|
+
} else {
|
|
1413
|
+
const ctMsg = await this.#waitForMessage(
|
|
1414
|
+
[MSG.KEY_EXCHANGE],
|
|
1415
|
+
timeout,
|
|
1416
|
+
'Timed out waiting for peer ML-KEM-768 ciphertext'
|
|
1417
|
+
);
|
|
1418
|
+
kemSharedSecret = await mlKemDecapsulate(localKem.secretKeySeed, new Uint8Array(ctMsg.kem_ciphertext));
|
|
1419
|
+
}
|
|
1188
1420
|
|
|
1189
|
-
|
|
1421
|
+
combinedBits = await combineHybridSecret(sharedBits, kemSharedSecret);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// Derive an AES-GCM key from the (possibly hybrid-combined) shared secret
|
|
1190
1425
|
const sharedSecret = await crypto.subtle.importKey(
|
|
1191
1426
|
'raw',
|
|
1192
|
-
|
|
1427
|
+
combinedBits,
|
|
1193
1428
|
{ name: 'AES-GCM', length: 256 },
|
|
1194
1429
|
false,
|
|
1195
1430
|
['encrypt', 'decrypt']
|
|
1196
1431
|
);
|
|
1197
1432
|
|
|
1198
|
-
return { sharedSecret, peerPublicKey: new Uint8Array(peerMsg.public_key) };
|
|
1433
|
+
return { sharedSecret, peerPublicKey: new Uint8Array(peerMsg.public_key), hybrid: hybridActive };
|
|
1199
1434
|
}
|
|
1200
1435
|
|
|
1201
1436
|
// ── Structured File Channel ───────────────────────────────────────
|
|
@@ -1502,6 +1737,89 @@ export class WshClient {
|
|
|
1502
1737
|
#handleControl(msg) {
|
|
1503
1738
|
const type = msg.type;
|
|
1504
1739
|
|
|
1740
|
+
// Unwrap RelayForward: only deliver the inner message if it came from a
|
|
1741
|
+
// peer this client has actually accepted a bridge with (see
|
|
1742
|
+
// trustRelayPeer), and only if the inner message's own type is on the
|
|
1743
|
+
// shared relay-forwardable allowlist (defense in depth against a
|
|
1744
|
+
// misbehaving or compromised relay server). from_fingerprint is set by
|
|
1745
|
+
// the server from the sender's authenticated identity -- never trust it
|
|
1746
|
+
// beyond checking membership in #acceptedRelayPeers.
|
|
1747
|
+
if (type === MSG.RELAY_FORWARD) {
|
|
1748
|
+
if (!this.#acceptedRelayPeers.has(msg.from_fingerprint)) {
|
|
1749
|
+
console.warn('[wsh:client] dropping RelayForward from untrusted/unaccepted peer:', msg.from_fingerprint);
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
let inner;
|
|
1753
|
+
try {
|
|
1754
|
+
inner = cborDecode(msg.inner);
|
|
1755
|
+
} catch (err) {
|
|
1756
|
+
console.error('[wsh:client] failed to decode RelayForward inner envelope:', err);
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
if (!isRelayForwardable(inner.type)) {
|
|
1760
|
+
console.warn('[wsh:client] dropping RelayForward wrapping a non-forwardable type:', inner.type);
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
this.#handleControl(inner);
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
// OPEN_OK/OPEN_FAIL: handled as a dedicated case (not the generic
|
|
1768
|
+
// waiter mechanism below) so the WshSession gets constructed and
|
|
1769
|
+
// registered in #sessions synchronously, in the same dispatch step as
|
|
1770
|
+
// OPEN_OK itself — see #pendingOpens's doc comment for why.
|
|
1771
|
+
if (type === MSG.OPEN_OK || type === MSG.OPEN_FAIL) {
|
|
1772
|
+
const pending = this.#pendingOpens.shift();
|
|
1773
|
+
if (!pending) return;
|
|
1774
|
+
clearTimeout(pending.timer);
|
|
1775
|
+
|
|
1776
|
+
// attachSession() wants the raw response either way and never
|
|
1777
|
+
// constructs a session -- it has no #sessions-registration race to
|
|
1778
|
+
// protect against.
|
|
1779
|
+
if (pending.mode === 'raw') {
|
|
1780
|
+
pending.resolve(msg);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
if (type === MSG.OPEN_FAIL) {
|
|
1785
|
+
pending.reject(new Error(`Failed to open session: ${msg.reason || 'rejected'}`));
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
const serverChannelId = msg.channel_id ?? pending.requestedChannelId;
|
|
1790
|
+
const streamIds = msg.stream_ids ?? {};
|
|
1791
|
+
const dataMode = msg.data_mode === 'virtual' ? 'virtual' : 'stream';
|
|
1792
|
+
const capabilities = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
1793
|
+
|
|
1794
|
+
const session = new WshSession(
|
|
1795
|
+
this.#transport,
|
|
1796
|
+
serverChannelId,
|
|
1797
|
+
streamIds,
|
|
1798
|
+
pending.kind,
|
|
1799
|
+
{ dataMode, capabilities }
|
|
1800
|
+
);
|
|
1801
|
+
this.#sessions.set(serverChannelId, session);
|
|
1802
|
+
|
|
1803
|
+
if (dataMode === 'virtual') {
|
|
1804
|
+
session._activateVirtual((m) => this.sendRelayControl(m));
|
|
1805
|
+
pending.resolve(session);
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// Stream mode needs a real transport stream, which is inherently
|
|
1810
|
+
// async — but #sessions.set() above already happened synchronously,
|
|
1811
|
+
// so channel-scoped control messages arriving before the stream
|
|
1812
|
+
// finishes binding still route correctly.
|
|
1813
|
+
this.#transport.openStream().then(
|
|
1814
|
+
(stream) => {
|
|
1815
|
+
session._bind(stream.readable, stream.writable);
|
|
1816
|
+
pending.resolve(session);
|
|
1817
|
+
},
|
|
1818
|
+
(err) => pending.reject(err)
|
|
1819
|
+
);
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1505
1823
|
// First, check if any waiters are listening for this message type.
|
|
1506
1824
|
if (this.#waiters.has(type)) {
|
|
1507
1825
|
const queue = this.#waiters.get(type);
|
|
@@ -1753,6 +2071,12 @@ export class WshClient {
|
|
|
1753
2071
|
}
|
|
1754
2072
|
}
|
|
1755
2073
|
this.#waiters.clear();
|
|
2074
|
+
|
|
2075
|
+
for (const pending of this.#pendingOpens) {
|
|
2076
|
+
clearTimeout(pending.timer);
|
|
2077
|
+
pending.reject(err);
|
|
2078
|
+
}
|
|
2079
|
+
this.#pendingOpens.length = 0;
|
|
1756
2080
|
}
|
|
1757
2081
|
|
|
1758
2082
|
// ── Internal: ping/pong keepalive ───────────────────────────────────
|
|
@@ -1802,22 +2126,15 @@ export class WshClient {
|
|
|
1802
2126
|
* that a client would not normally receive from the server, but that
|
|
1803
2127
|
* arrives via the relay bridge from a remote CLI peer.
|
|
1804
2128
|
*
|
|
2129
|
+
* Backed by the generated allowlist (single source of truth: the
|
|
2130
|
+
* `relay.forwardable` list in spec/wsh-v1.yaml) so this can no longer
|
|
2131
|
+
* drift out of sync with the server's own allowlist.
|
|
2132
|
+
*
|
|
1805
2133
|
* @param {number} type - Message opcode
|
|
1806
2134
|
* @returns {boolean}
|
|
1807
2135
|
*/
|
|
1808
2136
|
_isRelayForwardable(type) {
|
|
1809
|
-
return
|
|
1810
|
-
MSG.OPEN, MSG.MCP_DISCOVER, MSG.MCP_CALL,
|
|
1811
|
-
MSG.CLOSE, MSG.RESIZE, MSG.SIGNAL,
|
|
1812
|
-
MSG.SESSION_DATA,
|
|
1813
|
-
MSG.REVERSE_ACCEPT, MSG.REVERSE_REJECT,
|
|
1814
|
-
MSG.GUEST_JOIN, MSG.GUEST_REVOKE, // Unit 4
|
|
1815
|
-
MSG.COPILOT_ATTACH, MSG.COPILOT_DETACH, // Unit 9
|
|
1816
|
-
MSG.FILE_OP, // Unit 11
|
|
1817
|
-
MSG.POLICY_EVAL, // Unit 12
|
|
1818
|
-
MSG.ECHO_ACK, MSG.ECHO_STATE,
|
|
1819
|
-
MSG.TERM_SYNC, MSG.TERM_DIFF,
|
|
1820
|
-
].includes(type);
|
|
2137
|
+
return isRelayForwardable(type);
|
|
1821
2138
|
}
|
|
1822
2139
|
|
|
1823
2140
|
/**
|