@johnhenry/wsh 0.7.0 → 0.14.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 +549 -189
- package/src/file-transfer.mjs +39 -166
- package/src/index.d.ts +156 -2
- package/src/index.mjs +13 -0
- package/src/messages.gen.mjs +73 -19
- 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 +107 -1
- 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 ──────────────────────────────────────────────────────
|
|
@@ -126,8 +134,18 @@ export class WshClient {
|
|
|
126
134
|
/** @type {string|null} Session ID assigned by the server after authentication. */
|
|
127
135
|
#sessionId = null;
|
|
128
136
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
137
|
+
/**
|
|
138
|
+
* @type {string|null} Connection-level token from AUTH_OK, bound to
|
|
139
|
+
* this connection's own auth session_id (from CHALLENGE/SERVER_HELLO) --
|
|
140
|
+
* NOT a PTY/exec session's token. Renamed from `#resumeToken` (clawser
|
|
141
|
+
* #48): the old name invited exactly the bug this fix addresses --
|
|
142
|
+
* attachSession() used to send this as if it were a valid token for the
|
|
143
|
+
* *target* session_id, which it can never be (it's scoped to a
|
|
144
|
+
* different session_id entirely). Not used for anything else currently;
|
|
145
|
+
* kept and exposed via `authToken` for potential future connection-level
|
|
146
|
+
* resume use, mirroring the Rust client's `WshClient::token()`.
|
|
147
|
+
*/
|
|
148
|
+
#authToken = null;
|
|
131
149
|
|
|
132
150
|
/** @type {import('./transport.mjs').WshTransport|null} Active transport. */
|
|
133
151
|
#transport = null;
|
|
@@ -138,6 +156,31 @@ export class WshClient {
|
|
|
138
156
|
/** @type {Map<number, WshSession>} Active sessions keyed by channel ID. */
|
|
139
157
|
#sessions = new Map();
|
|
140
158
|
|
|
159
|
+
/**
|
|
160
|
+
* FIFO queue of in-flight openSession() calls awaiting the next
|
|
161
|
+
* OPEN_OK/OPEN_FAIL. Handled as a dedicated synchronous case in
|
|
162
|
+
* #handleControl (not the generic #waitForMessage machinery) so that
|
|
163
|
+
* #sessions.set() happens in the same synchronous dispatch step as
|
|
164
|
+
* OPEN_OK itself, with no microtask hop in between. That matters
|
|
165
|
+
* because a server can legitimately push channel-scoped data (e.g. the
|
|
166
|
+
* first FileChunk of a download) immediately after OPEN_OK, landing in
|
|
167
|
+
* the same message batch -- dispatchSerially only guarantees one
|
|
168
|
+
* microtask tick of separation between batch items, and registering the
|
|
169
|
+
* session via a promise continuation takes more hops than that, so the
|
|
170
|
+
* next item in the batch could still find #sessions.has(channelId)
|
|
171
|
+
* false and be misrouted/dropped.
|
|
172
|
+
* @type {Array<{kind: string, resolve: function, reject: function, timer: number}>}
|
|
173
|
+
*/
|
|
174
|
+
#pendingOpens = [];
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Fingerprints of reverse-connect peers this client has accepted a relay
|
|
178
|
+
* bridge with. RelayForward-wrapped messages are only unwrapped and
|
|
179
|
+
* delivered if their from_fingerprint is in this set — see trustRelayPeer.
|
|
180
|
+
* @type {Set<string>}
|
|
181
|
+
*/
|
|
182
|
+
#acceptedRelayPeers = new Set();
|
|
183
|
+
|
|
141
184
|
/** @type {number} Monotonically increasing channel ID counter. */
|
|
142
185
|
#channelCounter = 0;
|
|
143
186
|
|
|
@@ -249,6 +292,16 @@ export class WshClient {
|
|
|
249
292
|
return this.#sessionId;
|
|
250
293
|
}
|
|
251
294
|
|
|
295
|
+
/**
|
|
296
|
+
* This connection's own AUTH-level token (from AUTH_OK), scoped to this
|
|
297
|
+
* connection's auth session_id -- NOT a PTY/exec session token. See
|
|
298
|
+
* `WshSession.resumeToken` for the per-session credential used by
|
|
299
|
+
* `resumeSession`.
|
|
300
|
+
*/
|
|
301
|
+
get authToken() {
|
|
302
|
+
return this.#authToken;
|
|
303
|
+
}
|
|
304
|
+
|
|
252
305
|
/** Read-only view of active sessions. */
|
|
253
306
|
get sessions() {
|
|
254
307
|
return new Map(this.#sessions);
|
|
@@ -308,7 +361,7 @@ export class WshClient {
|
|
|
308
361
|
this.#channelCounter = 0;
|
|
309
362
|
this.#waiters.clear();
|
|
310
363
|
this.#sessionId = null;
|
|
311
|
-
this.#
|
|
364
|
+
this.#authToken = null;
|
|
312
365
|
|
|
313
366
|
try {
|
|
314
367
|
// ── Select and connect transport ──────────────────────────────
|
|
@@ -351,7 +404,7 @@ export class WshClient {
|
|
|
351
404
|
if (challengeMsg.type === MSG.AUTH_OK) {
|
|
352
405
|
// Server accepted without challenge (e.g. trusted key).
|
|
353
406
|
this.#sessionId = challengeMsg.session_id || tempSessionId;
|
|
354
|
-
this.#
|
|
407
|
+
this.#authToken = challengeMsg.token || null;
|
|
355
408
|
this.#state = STATE_AUTHENTICATED;
|
|
356
409
|
this.#startPing();
|
|
357
410
|
return this.#sessionId;
|
|
@@ -427,7 +480,7 @@ export class WshClient {
|
|
|
427
480
|
}
|
|
428
481
|
|
|
429
482
|
this.#sessionId = authResult.session_id || tempSessionId;
|
|
430
|
-
this.#
|
|
483
|
+
this.#authToken = authResult.token || null;
|
|
431
484
|
this.#state = STATE_AUTHENTICATED;
|
|
432
485
|
this.#startPing();
|
|
433
486
|
|
|
@@ -465,42 +518,25 @@ export class WshClient {
|
|
|
465
518
|
openMsg({ kind: type, command, cols, rows, env })
|
|
466
519
|
);
|
|
467
520
|
|
|
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;
|
|
521
|
+
// Registered as a dedicated pending-open (not the generic
|
|
522
|
+
// #waitForMessage waiter) so #handleControl can construct and
|
|
523
|
+
// register the WshSession synchronously the instant OPEN_OK arrives
|
|
524
|
+
// — see #pendingOpens's doc comment for why that matters.
|
|
525
|
+
return new Promise((resolve, reject) => {
|
|
526
|
+
const entry = {
|
|
527
|
+
mode: 'session',
|
|
528
|
+
kind: type,
|
|
529
|
+
requestedChannelId,
|
|
530
|
+
resolve,
|
|
531
|
+
reject,
|
|
532
|
+
};
|
|
533
|
+
entry.timer = setTimeout(() => {
|
|
534
|
+
const idx = this.#pendingOpens.indexOf(entry);
|
|
535
|
+
if (idx !== -1) this.#pendingOpens.splice(idx, 1);
|
|
536
|
+
reject(new Error('Timed out waiting for session open response'));
|
|
537
|
+
}, timeout);
|
|
538
|
+
this.#pendingOpens.push(entry);
|
|
539
|
+
});
|
|
504
540
|
}
|
|
505
541
|
|
|
506
542
|
/**
|
|
@@ -522,28 +558,48 @@ export class WshClient {
|
|
|
522
558
|
/**
|
|
523
559
|
* Attach to an existing remote session (collaborative or read-only).
|
|
524
560
|
*
|
|
561
|
+
* Authorization is satisfied by EITHER `token` OR the caller already
|
|
562
|
+
* owning/being ACL-granted access to `targetSessionId` server-side (see
|
|
563
|
+
* wsh-server's `check_session_access`) -- `token` is optional and, for
|
|
564
|
+
* the common case of attaching via ownership or a `grantSessionAccess`
|
|
565
|
+
* grant, should simply be omitted (a granted principal never receives
|
|
566
|
+
* the session's token to begin with -- only the opener does, via
|
|
567
|
+
* `WshSession.resumeToken`). Pass one explicitly only if you have it
|
|
568
|
+
* (e.g. this same process opened the session earlier, or the owner
|
|
569
|
+
* shared it with you out of band).
|
|
570
|
+
*
|
|
571
|
+
* Previously (clawser #48) this always sent this connection's own
|
|
572
|
+
* AUTH-level token, which can never verify against a *different*
|
|
573
|
+
* session_id -- making attachSession() unreachable for everyone,
|
|
574
|
+
* including the session's own owner from a fresh connection. Also fixed
|
|
575
|
+
* alongside that: the server actually replies with PRESENCE (success)
|
|
576
|
+
* or ERROR (failure), not OPEN_OK/OPEN_FAIL -- this used to wait on the
|
|
577
|
+
* wrong response stream entirely and would have hung even once the
|
|
578
|
+
* token bug was fixed.
|
|
579
|
+
*
|
|
525
580
|
* @param {string} targetSessionId - Remote session ID to attach to
|
|
526
581
|
* @param {object} [opts]
|
|
527
582
|
* @param {boolean} [opts.readOnly=false] - Attach in read-only mode
|
|
583
|
+
* @param {Uint8Array} [opts.token] - Session-scoped token (optional; see above)
|
|
528
584
|
* @param {number} [opts.timeout] - Timeout in ms
|
|
529
|
-
* @returns {Promise<object>} Server's response
|
|
585
|
+
* @returns {Promise<object>} Server's PRESENCE response
|
|
530
586
|
*/
|
|
531
|
-
async attachSession(targetSessionId, { readOnly = false, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
|
|
587
|
+
async attachSession(targetSessionId, { readOnly = false, token, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
|
|
532
588
|
this.#assertAuthenticated('attachSession');
|
|
533
589
|
|
|
534
|
-
const
|
|
590
|
+
const attachMode = readOnly ? 'readonly' : 'control';
|
|
535
591
|
await this.#transport.sendControl(
|
|
536
|
-
attachMsg({ sessionId: targetSessionId, token
|
|
592
|
+
attachMsg({ sessionId: targetSessionId, token, mode: attachMode })
|
|
537
593
|
);
|
|
538
594
|
|
|
539
595
|
const response = await this.#waitForMessage(
|
|
540
|
-
[MSG.
|
|
596
|
+
[MSG.PRESENCE, MSG.ERROR],
|
|
541
597
|
timeout,
|
|
542
598
|
'Timed out waiting for attach response'
|
|
543
599
|
);
|
|
544
600
|
|
|
545
|
-
if (response.type === MSG.
|
|
546
|
-
throw new Error(`Failed to attach: ${response.
|
|
601
|
+
if (response.type === MSG.ERROR) {
|
|
602
|
+
throw new Error(`Failed to attach: ${response.message || 'rejected'}`);
|
|
547
603
|
}
|
|
548
604
|
|
|
549
605
|
return response;
|
|
@@ -552,31 +608,120 @@ export class WshClient {
|
|
|
552
608
|
/**
|
|
553
609
|
* Resume a previously disconnected session.
|
|
554
610
|
*
|
|
611
|
+
* Unlike attachSession(), `token` is required and verified
|
|
612
|
+
* unconditionally server-side -- Resume is specifically for the
|
|
613
|
+
* connection that was handed this exact token (via
|
|
614
|
+
* `WshSession.resumeToken`, when it originally opened the session)
|
|
615
|
+
* coming back. A principal who only has ACL/ownership access but never
|
|
616
|
+
* held the token should use attachSession() instead.
|
|
617
|
+
*
|
|
555
618
|
* @param {string} targetSessionId - Session ID to resume
|
|
556
|
-
* @param {
|
|
557
|
-
* @param {
|
|
558
|
-
* @
|
|
619
|
+
* @param {Uint8Array} token - Session-scoped resume token (see `WshSession.resumeToken`)
|
|
620
|
+
* @param {object} [opts]
|
|
621
|
+
* @param {number} [opts.lastSeq=0] - Last sequence number this client already has (currently advisory -- the ring buffer replays its full contents regardless)
|
|
622
|
+
* @param {number} [opts.timeout=10000] - Timeout in ms
|
|
623
|
+
* @returns {Promise<object>} Server's PRESENCE response
|
|
559
624
|
*/
|
|
560
|
-
async resumeSession(targetSessionId, token, { timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
|
|
625
|
+
async resumeSession(targetSessionId, token, { lastSeq = 0, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
|
|
561
626
|
this.#assertAuthenticated('resumeSession');
|
|
562
627
|
|
|
563
628
|
await this.#transport.sendControl(
|
|
564
|
-
resumeMsg({ sessionId: targetSessionId, token })
|
|
629
|
+
resumeMsg({ sessionId: targetSessionId, token, lastSeq })
|
|
565
630
|
);
|
|
566
631
|
|
|
567
632
|
const response = await this.#waitForMessage(
|
|
568
|
-
[MSG.
|
|
633
|
+
[MSG.PRESENCE, MSG.ERROR],
|
|
569
634
|
timeout,
|
|
570
635
|
'Timed out waiting for resume response'
|
|
571
636
|
);
|
|
572
637
|
|
|
573
|
-
if (response.type === MSG.
|
|
574
|
-
throw new Error(`Failed to resume: ${response.
|
|
638
|
+
if (response.type === MSG.ERROR) {
|
|
639
|
+
throw new Error(`Failed to resume: ${response.message || 'rejected'}`);
|
|
575
640
|
}
|
|
576
641
|
|
|
577
642
|
return response;
|
|
578
643
|
}
|
|
579
644
|
|
|
645
|
+
/**
|
|
646
|
+
* Detach from a remote session: release control (stop receiving its
|
|
647
|
+
* output) while leaving it running server-side, so it can be resumed
|
|
648
|
+
* later via resumeSession(). Mirrors the Rust client/CLI's `wsh detach`.
|
|
649
|
+
*
|
|
650
|
+
* @param {string} sessionId - Remote session ID to detach from
|
|
651
|
+
* @param {number} [timeout=10000]
|
|
652
|
+
*/
|
|
653
|
+
async detach(sessionId, timeout = DEFAULT_OPEN_TIMEOUT) {
|
|
654
|
+
this.#assertAuthenticated('detach');
|
|
655
|
+
|
|
656
|
+
await this.#transport.sendControl(detachMsg({ sessionId }));
|
|
657
|
+
|
|
658
|
+
const response = await this.#waitForMessage(
|
|
659
|
+
[MSG.DETACH_OK, MSG.DETACH_FAIL],
|
|
660
|
+
timeout,
|
|
661
|
+
'Timed out waiting for detach response'
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
if (response.type === MSG.DETACH_FAIL) {
|
|
665
|
+
throw new Error(`Failed to detach: ${response.reason || 'rejected'}`);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* List sessions on the server that this connection's key owns or has
|
|
671
|
+
* been granted access to (a server round trip — distinct from the
|
|
672
|
+
* purely local listSessions(), which only reports channels open on
|
|
673
|
+
* this connection). Mirrors the Rust client's list_remote_sessions().
|
|
674
|
+
*
|
|
675
|
+
* @param {number} [timeout=10000]
|
|
676
|
+
* @returns {Promise<Array<object>>} Server-reported session summaries
|
|
677
|
+
*/
|
|
678
|
+
async listRemoteSessions(timeout = DEFAULT_OPEN_TIMEOUT) {
|
|
679
|
+
this.#assertAuthenticated('listRemoteSessions');
|
|
680
|
+
|
|
681
|
+
await this.#transport.sendControl(sessionListRequestMsg());
|
|
682
|
+
|
|
683
|
+
const response = await this.#waitForMessage(
|
|
684
|
+
[MSG.SESSION_LIST],
|
|
685
|
+
timeout,
|
|
686
|
+
'Timed out waiting for session list response'
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
return response.sessions || [];
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Grant another authenticated principal (username or fingerprint)
|
|
694
|
+
* access to a session this connection owns. Server-enforced (only the
|
|
695
|
+
* session owner may grant); fire-and-forget like updatePolicy() — the
|
|
696
|
+
* server sends no response on success, only an Error envelope on
|
|
697
|
+
* rejection (surfaced via onError, not this call).
|
|
698
|
+
*
|
|
699
|
+
* @param {string} sessionId
|
|
700
|
+
* @param {string} principal - Username or fingerprint of the grantee
|
|
701
|
+
* @param {string[]} [permissions=['read']]
|
|
702
|
+
*/
|
|
703
|
+
async grantSessionAccess(sessionId, principal, permissions = ['read']) {
|
|
704
|
+
this.#assertAuthenticated('grantSessionAccess');
|
|
705
|
+
await this.#transport.sendControl(
|
|
706
|
+
sessionGrantMsg({ sessionId, principal, permissions })
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Revoke a previously granted principal's access to a session this
|
|
712
|
+
* connection owns. Same fire-and-forget shape as grantSessionAccess().
|
|
713
|
+
*
|
|
714
|
+
* @param {string} sessionId
|
|
715
|
+
* @param {string} principal
|
|
716
|
+
* @param {string} [reason]
|
|
717
|
+
*/
|
|
718
|
+
async revokeSessionAccess(sessionId, principal, reason) {
|
|
719
|
+
this.#assertAuthenticated('revokeSessionAccess');
|
|
720
|
+
await this.#transport.sendControl(
|
|
721
|
+
sessionRevokeMsg({ sessionId, principal, reason })
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
|
|
580
725
|
// ── Disconnect ──────────────────────────────────────────────────────
|
|
581
726
|
|
|
582
727
|
/**
|
|
@@ -714,19 +859,37 @@ export class WshClient {
|
|
|
714
859
|
}
|
|
715
860
|
|
|
716
861
|
const effectiveShellBackend = shellBackend || (expose.shell ? 'virtual-shell' : 'exec-only');
|
|
862
|
+
const record = {
|
|
863
|
+
username,
|
|
864
|
+
capabilities,
|
|
865
|
+
peerType,
|
|
866
|
+
shellBackend: effectiveShellBackend,
|
|
867
|
+
supportsAttach: supportsAttach ?? effectiveShellBackend !== 'exec-only',
|
|
868
|
+
supportsReplay: supportsReplay ?? effectiveShellBackend !== 'exec-only',
|
|
869
|
+
supportsEcho: supportsEcho ?? effectiveShellBackend === 'virtual-shell',
|
|
870
|
+
supportsTermSync: supportsTermSync ?? effectiveShellBackend === 'virtual-shell',
|
|
871
|
+
// The peer's own monotonic counter for this signed record --
|
|
872
|
+
// current-time-millis in practice (see buildPeerRecordTranscript).
|
|
873
|
+
// A server/operator must reject a record whose seq doesn't exceed
|
|
874
|
+
// the last one accepted for this fingerprint.
|
|
875
|
+
seq: Date.now(),
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
// Self-sign the registration so ReversePeers entries built from it
|
|
879
|
+
// are verifiable by an operator independent of trusting the relay
|
|
880
|
+
// (see auth.mjs's "Signed peer records" section). Only possible with
|
|
881
|
+
// a real identity key, same precondition `publicKey` already had.
|
|
882
|
+
let recordSignature;
|
|
883
|
+
if (keyPair) {
|
|
884
|
+
({ signature: recordSignature } = await signPeerRecord(keyPair.privateKey, keyPair.publicKey, record));
|
|
885
|
+
}
|
|
717
886
|
|
|
718
887
|
// Register as a reverse peer.
|
|
719
888
|
await this.#transport.sendControl(
|
|
720
889
|
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',
|
|
890
|
+
...record,
|
|
729
891
|
publicKey,
|
|
892
|
+
recordSignature,
|
|
730
893
|
})
|
|
731
894
|
);
|
|
732
895
|
|
|
@@ -736,8 +899,19 @@ export class WshClient {
|
|
|
736
899
|
/**
|
|
737
900
|
* List peers registered on the relay server.
|
|
738
901
|
*
|
|
902
|
+
* Each entry's `verified` field is computed here, client-side, from
|
|
903
|
+
* the peer's own signed record (`public_key`/`seq`/`record_signature`,
|
|
904
|
+
* present when the relay honestly forwards what the peer sent) --
|
|
905
|
+
* `true` only if the signature actually verifies against `public_key`
|
|
906
|
+
* AND that key's fingerprint matches the claimed `fingerprint` field.
|
|
907
|
+
* This is deliberately NOT trust-on-first-use of the relay's own
|
|
908
|
+
* claims: a relay that omits or tampers with these fields, or that
|
|
909
|
+
* substitutes a different key, produces `verified: false` rather than
|
|
910
|
+
* silently passing. `verified` is additive to the wire response, not
|
|
911
|
+
* a wire field itself.
|
|
912
|
+
*
|
|
739
913
|
* @param {number} [timeout=10000] - Timeout in ms
|
|
740
|
-
* @returns {Promise<Array<{fingerprint_short: string, username: string, capabilities: string[], last_seen: number|null}>>}
|
|
914
|
+
* @returns {Promise<Array<{fingerprint_short: string, username: string, capabilities: string[], last_seen: number|null, verified: boolean}>>}
|
|
741
915
|
*/
|
|
742
916
|
async listPeers(timeout = DEFAULT_OPEN_TIMEOUT) {
|
|
743
917
|
this.#assertAuthenticated('listPeers');
|
|
@@ -750,7 +924,8 @@ export class WshClient {
|
|
|
750
924
|
'Timed out waiting for peer list'
|
|
751
925
|
);
|
|
752
926
|
|
|
753
|
-
|
|
927
|
+
const peers = response.peers || [];
|
|
928
|
+
return Promise.all(peers.map(async (peer) => ({ ...peer, verified: await verifyPeerInfoRecord(peer) })));
|
|
754
929
|
}
|
|
755
930
|
|
|
756
931
|
/**
|
|
@@ -778,14 +953,22 @@ export class WshClient {
|
|
|
778
953
|
this.#assertAuthenticated('reverseConnect');
|
|
779
954
|
|
|
780
955
|
await this.#transport.sendControl(
|
|
781
|
-
|
|
956
|
+
// fromFingerprint is ignored by the server -- it overwrites it with
|
|
957
|
+
// this connection's own authenticated fingerprint before forwarding.
|
|
958
|
+
reverseConnectMsg({ targetFingerprint, username: '', fromFingerprint: '' })
|
|
782
959
|
);
|
|
783
960
|
|
|
784
|
-
|
|
961
|
+
const response = await this.#waitForMessage(
|
|
785
962
|
[MSG.REVERSE_ACCEPT, MSG.REVERSE_REJECT],
|
|
786
963
|
timeout,
|
|
787
964
|
'Timed out waiting for reverse-connect response'
|
|
788
965
|
);
|
|
966
|
+
|
|
967
|
+
if (response.type === MSG.REVERSE_ACCEPT) {
|
|
968
|
+
this.trustRelayPeer(response.target_fingerprint);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return response;
|
|
789
972
|
}
|
|
790
973
|
|
|
791
974
|
/**
|
|
@@ -802,12 +985,40 @@ export class WshClient {
|
|
|
802
985
|
await this.#transport.sendControl(msg);
|
|
803
986
|
}
|
|
804
987
|
|
|
988
|
+
/**
|
|
989
|
+
* Mark a peer fingerprint as an accepted reverse-connect bridge partner.
|
|
990
|
+
*
|
|
991
|
+
* Call this once a ReverseConnect has been accepted (either side): the
|
|
992
|
+
* target after sending ReverseAccept in response to an incoming request
|
|
993
|
+
* (using the request's from_fingerprint), or the operator after receiving
|
|
994
|
+
* ReverseAccept for a request it sent (handled automatically by
|
|
995
|
+
* reverseConnect()). Only RelayForward-wrapped messages whose
|
|
996
|
+
* from_fingerprint is trusted this way are unwrapped and delivered.
|
|
997
|
+
*
|
|
998
|
+
* @param {string} fingerprint
|
|
999
|
+
*/
|
|
1000
|
+
trustRelayPeer(fingerprint) {
|
|
1001
|
+
this.#acceptedRelayPeers.add(fingerprint);
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Stop trusting a peer as a relay-forward bridge partner (e.g. on session end).
|
|
1006
|
+
* @param {string} fingerprint
|
|
1007
|
+
*/
|
|
1008
|
+
untrustRelayPeer(fingerprint) {
|
|
1009
|
+
this.#acceptedRelayPeers.delete(fingerprint);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
805
1012
|
// ── File transfer ───────────────────────────────────────────────────
|
|
806
1013
|
|
|
807
1014
|
/**
|
|
808
1015
|
* Upload a blob to a remote path.
|
|
809
1016
|
*
|
|
810
|
-
* Opens a file channel,
|
|
1017
|
+
* Opens a file channel, then sends the data as a sequence of FileChunk
|
|
1018
|
+
* control messages (offset-addressed, the last one marked is_final) and
|
|
1019
|
+
* waits for the server to confirm completion. Works the same whether the
|
|
1020
|
+
* channel's data plane is stream- or virtual-backed, since FileChunk is
|
|
1021
|
+
* an ordinary control message rather than raw stream bytes.
|
|
811
1022
|
*
|
|
812
1023
|
* @param {Blob|Uint8Array} blob - Data to upload
|
|
813
1024
|
* @param {string} remotePath - Destination path on the server
|
|
@@ -821,21 +1032,31 @@ export class WshClient {
|
|
|
821
1032
|
? new Uint8Array(await blob.arrayBuffer())
|
|
822
1033
|
: blob;
|
|
823
1034
|
const total = data.byteLength;
|
|
824
|
-
const header = buildUploadHeader(remotePath, total);
|
|
825
|
-
let sent = 0;
|
|
826
1035
|
|
|
827
1036
|
try {
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
const end = Math.min(
|
|
831
|
-
await
|
|
1037
|
+
let sent = 0;
|
|
1038
|
+
do {
|
|
1039
|
+
const end = Math.min(sent + FILE_CHUNK_SIZE, total);
|
|
1040
|
+
await this.#transport.sendControl(fileChunkMsg({
|
|
1041
|
+
channelId: session.channelId,
|
|
1042
|
+
offset: sent,
|
|
1043
|
+
data: data.subarray(sent, end),
|
|
1044
|
+
isFinal: end >= total,
|
|
1045
|
+
totalSize: total,
|
|
1046
|
+
}));
|
|
832
1047
|
sent = end;
|
|
833
1048
|
onProgress?.(sent);
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
1049
|
+
} while (sent < total);
|
|
1050
|
+
|
|
1051
|
+
// The server confirms completion the same way an exec session
|
|
1052
|
+
// signals it finished: Exit with a code, then Close. Resolve on
|
|
1053
|
+
// whichever arrives first.
|
|
1054
|
+
const code = await new Promise((resolve) => {
|
|
1055
|
+
session.onExit = (c) => { session.onExit = null; resolve(c); };
|
|
1056
|
+
session.onClose = () => resolve(null);
|
|
1057
|
+
});
|
|
1058
|
+
if (code !== 0) {
|
|
1059
|
+
throw new Error(`Upload failed with exit code ${code ?? 'unknown'}`);
|
|
839
1060
|
}
|
|
840
1061
|
} finally {
|
|
841
1062
|
await session.close().catch(() => {});
|
|
@@ -845,36 +1066,45 @@ export class WshClient {
|
|
|
845
1066
|
/**
|
|
846
1067
|
* Download a file from a remote path.
|
|
847
1068
|
*
|
|
1069
|
+
* Opens a file channel and reads the file as a sequence of FileChunk
|
|
1070
|
+
* control messages until the final chunk arrives. offset/total_size are
|
|
1071
|
+
* checked so a truncated transfer (channel closes before is_final, or
|
|
1072
|
+
* the final chunk doesn't actually reach total_size) is detected rather
|
|
1073
|
+
* than silently returned as a short file.
|
|
1074
|
+
*
|
|
848
1075
|
* @param {string} remotePath - Source path on the server
|
|
1076
|
+
* @param {object} [opts]
|
|
1077
|
+
* @param {function({received: number, total: number}): void} [opts.onProgress]
|
|
1078
|
+
* @param {number} [opts.timeout=10000] - Timeout in ms waiting for the channel to open
|
|
849
1079
|
* @returns {Promise<Uint8Array>} File contents
|
|
850
1080
|
*/
|
|
851
|
-
async download(remotePath) {
|
|
1081
|
+
async download(remotePath, { onProgress, timeout = DEFAULT_OPEN_TIMEOUT } = {}) {
|
|
852
1082
|
this.#assertAuthenticated('download');
|
|
853
|
-
const session = await this.openSession({ type: 'file', command: `download:${remotePath}
|
|
1083
|
+
const session = await this.openSession({ type: 'file', command: `download:${remotePath}`, timeout });
|
|
854
1084
|
|
|
855
1085
|
try {
|
|
856
|
-
|
|
857
|
-
|
|
1086
|
+
let data = null;
|
|
1087
|
+
let received = 0;
|
|
858
1088
|
|
|
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();
|
|
1089
|
+
while (true) {
|
|
1090
|
+
const chunk = await session._readFileChunk();
|
|
869
1091
|
if (chunk === null) {
|
|
870
|
-
throw new Error('Download failed:
|
|
1092
|
+
throw new Error('Download failed: connection closed before the transfer completed (truncated)');
|
|
871
1093
|
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1094
|
+
if (data === null) {
|
|
1095
|
+
data = new Uint8Array(chunk.total_size ?? 0);
|
|
1096
|
+
}
|
|
1097
|
+
data.set(chunk.data, chunk.offset);
|
|
1098
|
+
received = chunk.offset + chunk.data.byteLength;
|
|
1099
|
+
onProgress?.({ received, total: data.byteLength });
|
|
876
1100
|
|
|
877
|
-
|
|
1101
|
+
if (chunk.is_final) {
|
|
1102
|
+
if (received !== data.byteLength) {
|
|
1103
|
+
throw new Error(`Download failed: truncated transfer (received ${received} of ${data.byteLength} bytes)`);
|
|
1104
|
+
}
|
|
1105
|
+
return data;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
878
1108
|
} finally {
|
|
879
1109
|
await session.close().catch(() => {});
|
|
880
1110
|
}
|
|
@@ -1141,28 +1371,50 @@ export class WshClient {
|
|
|
1141
1371
|
// ── E2E Encryption ────────────────────────────────────────────────
|
|
1142
1372
|
|
|
1143
1373
|
/**
|
|
1144
|
-
* Initiate end-to-end encryption for a session
|
|
1374
|
+
* Initiate end-to-end encryption for a session.
|
|
1375
|
+
*
|
|
1376
|
+
* `algorithm: 'X25519'` (default): classical ECDH only, one round trip
|
|
1377
|
+
* -- both sides send their ephemeral public key, derive the shared
|
|
1378
|
+
* secret directly.
|
|
1379
|
+
*
|
|
1380
|
+
* `algorithm: 'X25519+ML-KEM-768'`: hybrid classical+post-quantum. Round
|
|
1381
|
+
* 1 is the same as classical, plus both sides also send a fresh
|
|
1382
|
+
* ML-KEM-768 public key. Each side then deterministically derives the
|
|
1383
|
+
* same "encapsulator"/"decapsulator" role assignment by comparing the
|
|
1384
|
+
* two exchanged X25519 public keys byte-lexicographically (no extra
|
|
1385
|
+
* round trip needed, since both sides already have both values after
|
|
1386
|
+
* round 1) -- the encapsulator encapsulates against the decapsulator's
|
|
1387
|
+
* ML-KEM-768 key and sends the ciphertext in a second KeyExchange
|
|
1388
|
+
* message; the decapsulator decapsulates it. Both combine the X25519
|
|
1389
|
+
* and ML-KEM-768 outputs via HKDF-SHA256. Falls back to classical
|
|
1390
|
+
* automatically if the peer's round-1 message doesn't include a
|
|
1391
|
+
* kem_public_key (it doesn't support hybrid mode) -- algorithm
|
|
1392
|
+
* agility, not a hard cutover; check the returned `hybrid` flag to see
|
|
1393
|
+
* which actually happened.
|
|
1394
|
+
*
|
|
1145
1395
|
* @param {string} sessionId - Session ID
|
|
1146
|
-
* @param {string} [algorithm='X25519'] -
|
|
1396
|
+
* @param {string} [algorithm='X25519'] - 'X25519' or 'X25519+ML-KEM-768'
|
|
1147
1397
|
* @param {number} [timeout=10000]
|
|
1148
|
-
* @returns {Promise<{sharedSecret: CryptoKey, peerPublicKey: Uint8Array}>}
|
|
1398
|
+
* @returns {Promise<{sharedSecret: CryptoKey, peerPublicKey: Uint8Array, hybrid: boolean}>}
|
|
1149
1399
|
*/
|
|
1150
1400
|
async initiateE2E(sessionId, algorithm = 'X25519', timeout = DEFAULT_OPEN_TIMEOUT) {
|
|
1151
1401
|
this.#assertAuthenticated('initiateE2E');
|
|
1402
|
+
const wantHybrid = algorithm === 'X25519+ML-KEM-768';
|
|
1152
1403
|
|
|
1153
|
-
// Generate ephemeral X25519 key pair
|
|
1404
|
+
// Generate ephemeral X25519 key pair (and, for hybrid, a fresh
|
|
1405
|
+
// ML-KEM-768 key pair too).
|
|
1154
1406
|
const ephemeral = await crypto.subtle.generateKey(
|
|
1155
1407
|
{ name: 'X25519' },
|
|
1156
1408
|
false,
|
|
1157
1409
|
['deriveBits']
|
|
1158
1410
|
);
|
|
1159
|
-
|
|
1160
1411
|
const localPub = new Uint8Array(
|
|
1161
1412
|
await crypto.subtle.exportKey('raw', ephemeral.publicKey)
|
|
1162
1413
|
);
|
|
1414
|
+
const localKem = wantHybrid ? await generateMlKemKeyPair() : null;
|
|
1163
1415
|
|
|
1164
1416
|
await this.#transport.sendControl(
|
|
1165
|
-
keyExchangeMsg({ algorithm, publicKey: localPub, sessionId })
|
|
1417
|
+
keyExchangeMsg({ algorithm, publicKey: localPub, sessionId, kemPublicKey: localKem?.publicKey })
|
|
1166
1418
|
);
|
|
1167
1419
|
|
|
1168
1420
|
const peerMsg = await this.#waitForMessage(
|
|
@@ -1171,7 +1423,7 @@ export class WshClient {
|
|
|
1171
1423
|
'Timed out waiting for peer key exchange'
|
|
1172
1424
|
);
|
|
1173
1425
|
|
|
1174
|
-
// Import peer's public key and derive shared secret
|
|
1426
|
+
// Import peer's public key and derive the classical shared secret.
|
|
1175
1427
|
const peerKey = await crypto.subtle.importKey(
|
|
1176
1428
|
'raw',
|
|
1177
1429
|
peerMsg.public_key,
|
|
@@ -1179,23 +1431,46 @@ export class WshClient {
|
|
|
1179
1431
|
false,
|
|
1180
1432
|
[]
|
|
1181
1433
|
);
|
|
1182
|
-
|
|
1183
|
-
const sharedBits = await crypto.subtle.deriveBits(
|
|
1434
|
+
const sharedBits = new Uint8Array(await crypto.subtle.deriveBits(
|
|
1184
1435
|
{ name: 'X25519', public: peerKey },
|
|
1185
1436
|
ephemeral.privateKey,
|
|
1186
1437
|
256
|
|
1187
|
-
);
|
|
1438
|
+
));
|
|
1439
|
+
|
|
1440
|
+
const hybridActive = wantHybrid && !!localKem && !!peerMsg.kem_public_key;
|
|
1441
|
+
let combinedBits = sharedBits;
|
|
1442
|
+
|
|
1443
|
+
if (hybridActive) {
|
|
1444
|
+
const peerKemPublicKey = new Uint8Array(peerMsg.kem_public_key);
|
|
1445
|
+
const isEncapsulator = compareBytes(localPub, new Uint8Array(peerMsg.public_key)) < 0;
|
|
1446
|
+
|
|
1447
|
+
let kemSharedSecret;
|
|
1448
|
+
if (isEncapsulator) {
|
|
1449
|
+
const { ciphertext, sharedSecret } = await mlKemEncapsulate(peerKemPublicKey);
|
|
1450
|
+
kemSharedSecret = sharedSecret;
|
|
1451
|
+
await this.#transport.sendControl(keyExchangeMsg({ algorithm, sessionId, kemCiphertext: ciphertext }));
|
|
1452
|
+
} else {
|
|
1453
|
+
const ctMsg = await this.#waitForMessage(
|
|
1454
|
+
[MSG.KEY_EXCHANGE],
|
|
1455
|
+
timeout,
|
|
1456
|
+
'Timed out waiting for peer ML-KEM-768 ciphertext'
|
|
1457
|
+
);
|
|
1458
|
+
kemSharedSecret = await mlKemDecapsulate(localKem.secretKeySeed, new Uint8Array(ctMsg.kem_ciphertext));
|
|
1459
|
+
}
|
|
1188
1460
|
|
|
1189
|
-
|
|
1461
|
+
combinedBits = await combineHybridSecret(sharedBits, kemSharedSecret);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Derive an AES-GCM key from the (possibly hybrid-combined) shared secret
|
|
1190
1465
|
const sharedSecret = await crypto.subtle.importKey(
|
|
1191
1466
|
'raw',
|
|
1192
|
-
|
|
1467
|
+
combinedBits,
|
|
1193
1468
|
{ name: 'AES-GCM', length: 256 },
|
|
1194
1469
|
false,
|
|
1195
1470
|
['encrypt', 'decrypt']
|
|
1196
1471
|
);
|
|
1197
1472
|
|
|
1198
|
-
return { sharedSecret, peerPublicKey: new Uint8Array(peerMsg.public_key) };
|
|
1473
|
+
return { sharedSecret, peerPublicKey: new Uint8Array(peerMsg.public_key), hybrid: hybridActive };
|
|
1199
1474
|
}
|
|
1200
1475
|
|
|
1201
1476
|
// ── Structured File Channel ───────────────────────────────────────
|
|
@@ -1432,7 +1707,7 @@ export class WshClient {
|
|
|
1432
1707
|
|
|
1433
1708
|
if (challengeMsg.type === MSG.AUTH_OK) {
|
|
1434
1709
|
this.#sessionId = challengeMsg.session_id || tempSessionId;
|
|
1435
|
-
this.#
|
|
1710
|
+
this.#authToken = challengeMsg.token || null;
|
|
1436
1711
|
this.#state = STATE_AUTHENTICATED;
|
|
1437
1712
|
this.#startPing();
|
|
1438
1713
|
return this.#sessionId;
|
|
@@ -1478,7 +1753,7 @@ export class WshClient {
|
|
|
1478
1753
|
}
|
|
1479
1754
|
|
|
1480
1755
|
this.#sessionId = authResult.session_id || tempSessionId;
|
|
1481
|
-
this.#
|
|
1756
|
+
this.#authToken = authResult.token || null;
|
|
1482
1757
|
this.#state = STATE_AUTHENTICATED;
|
|
1483
1758
|
this.#startPing();
|
|
1484
1759
|
|
|
@@ -1502,6 +1777,92 @@ export class WshClient {
|
|
|
1502
1777
|
#handleControl(msg) {
|
|
1503
1778
|
const type = msg.type;
|
|
1504
1779
|
|
|
1780
|
+
// Unwrap RelayForward: only deliver the inner message if it came from a
|
|
1781
|
+
// peer this client has actually accepted a bridge with (see
|
|
1782
|
+
// trustRelayPeer), and only if the inner message's own type is on the
|
|
1783
|
+
// shared relay-forwardable allowlist (defense in depth against a
|
|
1784
|
+
// misbehaving or compromised relay server). from_fingerprint is set by
|
|
1785
|
+
// the server from the sender's authenticated identity -- never trust it
|
|
1786
|
+
// beyond checking membership in #acceptedRelayPeers.
|
|
1787
|
+
if (type === MSG.RELAY_FORWARD) {
|
|
1788
|
+
if (!this.#acceptedRelayPeers.has(msg.from_fingerprint)) {
|
|
1789
|
+
console.warn('[wsh:client] dropping RelayForward from untrusted/unaccepted peer:', msg.from_fingerprint);
|
|
1790
|
+
return;
|
|
1791
|
+
}
|
|
1792
|
+
let inner;
|
|
1793
|
+
try {
|
|
1794
|
+
inner = cborDecode(msg.inner);
|
|
1795
|
+
} catch (err) {
|
|
1796
|
+
console.error('[wsh:client] failed to decode RelayForward inner envelope:', err);
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
if (!isRelayForwardable(inner.type)) {
|
|
1800
|
+
console.warn('[wsh:client] dropping RelayForward wrapping a non-forwardable type:', inner.type);
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
this.#handleControl(inner);
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
// OPEN_OK/OPEN_FAIL: handled as a dedicated case (not the generic
|
|
1808
|
+
// waiter mechanism below) so the WshSession gets constructed and
|
|
1809
|
+
// registered in #sessions synchronously, in the same dispatch step as
|
|
1810
|
+
// OPEN_OK itself — see #pendingOpens's doc comment for why.
|
|
1811
|
+
if (type === MSG.OPEN_OK || type === MSG.OPEN_FAIL) {
|
|
1812
|
+
const pending = this.#pendingOpens.shift();
|
|
1813
|
+
if (!pending) return;
|
|
1814
|
+
clearTimeout(pending.timer);
|
|
1815
|
+
|
|
1816
|
+
if (type === MSG.OPEN_FAIL) {
|
|
1817
|
+
pending.reject(new Error(`Failed to open session: ${msg.reason || 'rejected'}`));
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
const serverChannelId = msg.channel_id ?? pending.requestedChannelId;
|
|
1822
|
+
const streamIds = msg.stream_ids ?? {};
|
|
1823
|
+
const dataMode = msg.data_mode === 'virtual' ? 'virtual' : 'stream';
|
|
1824
|
+
const capabilities = Array.isArray(msg.capabilities) ? msg.capabilities : [];
|
|
1825
|
+
|
|
1826
|
+
const session = new WshSession(
|
|
1827
|
+
this.#transport,
|
|
1828
|
+
serverChannelId,
|
|
1829
|
+
streamIds,
|
|
1830
|
+
pending.kind,
|
|
1831
|
+
{
|
|
1832
|
+
dataMode,
|
|
1833
|
+
capabilities,
|
|
1834
|
+
// clawser #48: OpenOk now carries the session_id/token this
|
|
1835
|
+
// channel belongs to (pty/exec only -- undefined for e.g. file
|
|
1836
|
+
// channels, which have no Attach/Resume-able session). Exposed
|
|
1837
|
+
// via WshSession.sessionId/resumeToken for a later
|
|
1838
|
+
// attachSession()/resumeSession() call, possibly from a
|
|
1839
|
+
// different connection.
|
|
1840
|
+
sessionId: msg.session_id,
|
|
1841
|
+
resumeToken: msg.token,
|
|
1842
|
+
}
|
|
1843
|
+
);
|
|
1844
|
+
this.#sessions.set(serverChannelId, session);
|
|
1845
|
+
|
|
1846
|
+
if (dataMode === 'virtual') {
|
|
1847
|
+
session._activateVirtual((m) => this.sendRelayControl(m));
|
|
1848
|
+
pending.resolve(session);
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// Stream mode needs a real transport stream, which is inherently
|
|
1853
|
+
// async — but #sessions.set() above already happened synchronously,
|
|
1854
|
+
// so channel-scoped control messages arriving before the stream
|
|
1855
|
+
// finishes binding still route correctly.
|
|
1856
|
+
this.#transport.openStream().then(
|
|
1857
|
+
(stream) => {
|
|
1858
|
+
session._bind(stream.readable, stream.writable);
|
|
1859
|
+
pending.resolve(session);
|
|
1860
|
+
},
|
|
1861
|
+
(err) => pending.reject(err)
|
|
1862
|
+
);
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1505
1866
|
// First, check if any waiters are listening for this message type.
|
|
1506
1867
|
if (this.#waiters.has(type)) {
|
|
1507
1868
|
const queue = this.#waiters.get(type);
|
|
@@ -1753,6 +2114,12 @@ export class WshClient {
|
|
|
1753
2114
|
}
|
|
1754
2115
|
}
|
|
1755
2116
|
this.#waiters.clear();
|
|
2117
|
+
|
|
2118
|
+
for (const pending of this.#pendingOpens) {
|
|
2119
|
+
clearTimeout(pending.timer);
|
|
2120
|
+
pending.reject(err);
|
|
2121
|
+
}
|
|
2122
|
+
this.#pendingOpens.length = 0;
|
|
1756
2123
|
}
|
|
1757
2124
|
|
|
1758
2125
|
// ── Internal: ping/pong keepalive ───────────────────────────────────
|
|
@@ -1802,22 +2169,15 @@ export class WshClient {
|
|
|
1802
2169
|
* that a client would not normally receive from the server, but that
|
|
1803
2170
|
* arrives via the relay bridge from a remote CLI peer.
|
|
1804
2171
|
*
|
|
2172
|
+
* Backed by the generated allowlist (single source of truth: the
|
|
2173
|
+
* `relay.forwardable` list in spec/wsh-v1.yaml) so this can no longer
|
|
2174
|
+
* drift out of sync with the server's own allowlist.
|
|
2175
|
+
*
|
|
1805
2176
|
* @param {number} type - Message opcode
|
|
1806
2177
|
* @returns {boolean}
|
|
1807
2178
|
*/
|
|
1808
2179
|
_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);
|
|
2180
|
+
return isRelayForwardable(type);
|
|
1821
2181
|
}
|
|
1822
2182
|
|
|
1823
2183
|
/**
|