@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/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
- const textEncoder = new TextEncoder();
53
-
54
- function buildUploadHeader(path, totalSize) {
55
- const pathBytes = textEncoder.encode(path);
56
- const header = new Uint8Array(4 + pathBytes.length + 8);
57
- const view = new DataView(header.buffer);
58
- view.setUint32(0, pathBytes.length);
59
- header.set(pathBytes, 4);
60
- writeU64(header, 4 + pathBytes.length, totalSize);
61
- return header;
62
- }
63
-
64
- function buildDownloadHeader(path) {
65
- const pathBytes = textEncoder.encode(path);
66
- const header = new Uint8Array(4 + pathBytes.length);
67
- const view = new DataView(header.buffer);
68
- view.setUint32(0, pathBytes.length);
69
- header.set(pathBytes, 4);
70
- return header;
71
- }
72
-
73
- function writeU64(buffer, offset, value) {
74
- const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
75
- const big = BigInt(value);
76
- view.setBigUint64(offset, big);
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
- function decodeU64(bytes) {
80
- const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
81
- return Number(view.getBigUint64(0));
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
- function createSessionReader(session) {
85
- let remainder = new Uint8Array();
86
- return {
87
- async readExact(totalBytes) {
88
- const output = new Uint8Array(totalBytes);
89
- let offset = 0;
90
-
91
- while (offset < totalBytes) {
92
- if (remainder.byteLength === 0) {
93
- const chunk = await session.read();
94
- if (chunk === null) {
95
- return null;
96
- }
97
- remainder = chunk;
98
- }
99
-
100
- const take = Math.min(totalBytes - offset, remainder.byteLength);
101
- output.set(remainder.subarray(0, take), offset);
102
- offset += take;
103
- remainder = remainder.subarray(take);
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
- /** @type {string|null} Resume token from AUTH_OK. */
130
- #resumeToken = null;
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.#resumeToken = null;
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.#resumeToken = challengeMsg.token || null;
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.#resumeToken = authResult.token || null;
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
- // Wait for OPEN_OK or OPEN_FAIL.
469
- const response = await this.#waitForMessage(
470
- [MSG.OPEN_OK, MSG.OPEN_FAIL],
471
- timeout,
472
- 'Timed out waiting for session open response'
473
- );
474
-
475
- if (response.type === MSG.OPEN_FAIL) {
476
- throw new Error(`Failed to open session: ${response.reason || 'rejected'}`);
477
- }
478
-
479
- const serverChannelId = response.channel_id ?? requestedChannelId;
480
- const streamIds = response.stream_ids ?? {};
481
- const dataMode = response.data_mode === 'virtual' ? 'virtual' : 'stream';
482
- const capabilities = Array.isArray(response.capabilities) ? response.capabilities : [];
483
-
484
- // Create the session object.
485
- const session = new WshSession(
486
- this.#transport,
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 mode = readOnly ? 'readonly' : 'control';
590
+ const attachMode = readOnly ? 'readonly' : 'control';
535
591
  await this.#transport.sendControl(
536
- attachMsg({ sessionId: targetSessionId, token: this.#resumeToken, mode })
592
+ attachMsg({ sessionId: targetSessionId, token, mode: attachMode })
537
593
  );
538
594
 
539
595
  const response = await this.#waitForMessage(
540
- [MSG.OPEN_OK, MSG.OPEN_FAIL],
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.OPEN_FAIL) {
546
- throw new Error(`Failed to attach: ${response.reason || 'rejected'}`);
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 {string} token - Resume token from original AUTH_OK
557
- * @param {number} [opts.timeout] - Timeout in ms
558
- * @returns {Promise<object>} Server's response
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.AUTH_OK, MSG.AUTH_FAIL],
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.AUTH_FAIL) {
574
- throw new Error(`Failed to resume: ${response.reason || 'rejected'}`);
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
- username,
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
- return response.peers || [];
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
- reverseConnectMsg({ targetFingerprint, username: '' })
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
- return this.#waitForMessage(
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, writes the data, and waits for acknowledgment.
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
- await session.write(header);
829
- for (let i = 0; i < total; i += FILE_CHUNK_SIZE) {
830
- const end = Math.min(i + FILE_CHUNK_SIZE, total);
831
- await session.write(data.subarray(i, end));
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
- const ack = await session.read();
837
- if (ack === null) {
838
- throw new Error('Upload failed: unexpected EOF waiting for server acknowledgement');
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
- const reader = createSessionReader(session);
857
- await session.write(buildDownloadHeader(remotePath));
1086
+ let data = null;
1087
+ let received = 0;
858
1088
 
859
- const sizeChunk = await reader.readExact(8);
860
- if (sizeChunk === null) {
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: unexpected EOF reading file payload');
1092
+ throw new Error('Download failed: connection closed before the transfer completed (truncated)');
871
1093
  }
872
- const remaining = totalSize - offset;
873
- data.set(chunk.subarray(0, remaining), offset);
874
- offset += Math.min(chunk.byteLength, remaining);
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
- return data;
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 using X25519 key exchange.
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'] - Key exchange algorithm
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
- // Derive an AES-GCM key from the shared secret
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
- sharedBits,
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.#resumeToken = challengeMsg.token || null;
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.#resumeToken = authResult.token || null;
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
  /**