@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/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 ──────────────────────────────────────────────────────
@@ -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
- // 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;
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 mode = readOnly ? 'readonly' : 'control';
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
- const response = await this.#waitForMessage(
540
- [MSG.OPEN_OK, MSG.OPEN_FAIL],
541
- timeout,
542
- 'Timed out waiting for attach response'
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
- 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',
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
- return response.peers || [];
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
- reverseConnectMsg({ targetFingerprint, username: '' })
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
- return this.#waitForMessage(
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, writes the data, and waits for acknowledgment.
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
- 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));
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
- const ack = await session.read();
837
- if (ack === null) {
838
- throw new Error('Upload failed: unexpected EOF waiting for server acknowledgement');
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
- const reader = createSessionReader(session);
857
- await session.write(buildDownloadHeader(remotePath));
1046
+ let data = null;
1047
+ let received = 0;
858
1048
 
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();
1049
+ while (true) {
1050
+ const chunk = await session._readFileChunk();
869
1051
  if (chunk === null) {
870
- throw new Error('Download failed: unexpected EOF reading file payload');
1052
+ throw new Error('Download failed: connection closed before the transfer completed (truncated)');
871
1053
  }
872
- const remaining = totalSize - offset;
873
- data.set(chunk.subarray(0, remaining), offset);
874
- offset += Math.min(chunk.byteLength, remaining);
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
- return data;
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 using X25519 key exchange.
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'] - Key exchange algorithm
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
- // Derive an AES-GCM key from the shared secret
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
- sharedBits,
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
  /**