@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/file-transfer.mjs
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* WshFileTransfer —
|
|
2
|
+
* WshFileTransfer — scp-like file transfer over a wsh connection.
|
|
3
3
|
*
|
|
4
|
-
* Uploads and downloads
|
|
5
|
-
*
|
|
4
|
+
* Uploads and downloads delegate to the underlying client's upload()/
|
|
5
|
+
* download() (FileChunk control messages over a 'file'-kind channel —
|
|
6
|
+
* see client.mjs). list() is a separate helper that runs `ls -la` over
|
|
7
|
+
* a plain exec channel.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
import { MSG, open, CHANNEL_KIND } from './messages.mjs';
|
|
9
11
|
|
|
10
|
-
/** Transfer chunk size: 64KB. */
|
|
11
|
-
const CHUNK_SIZE = 65536;
|
|
12
|
-
|
|
13
12
|
/** Default timeout for waiting on control messages (30 seconds). */
|
|
14
13
|
const RESPONSE_TIMEOUT_MS = 30_000;
|
|
15
14
|
|
|
16
15
|
export class WshFileTransfer {
|
|
17
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* @type {{
|
|
18
|
+
* upload: function, download: function,
|
|
19
|
+
* sendControl: function, openStream: function, onControl: function,
|
|
20
|
+
* }}
|
|
21
|
+
*/
|
|
18
22
|
#client;
|
|
19
23
|
|
|
20
24
|
/**
|
|
21
|
-
* @param {object} client - A WshClient or any
|
|
25
|
+
* @param {object} client - A WshClient (or any object exposing upload()/
|
|
26
|
+
* download() with the same signatures — those are used directly for
|
|
27
|
+
* upload/download). list() additionally needs, for the underlying exec
|
|
28
|
+
* channel it runs `ls -la` over:
|
|
22
29
|
* - sendControl(msg): send a control message
|
|
23
30
|
* - openStream(): open a new bidirectional stream
|
|
24
31
|
* - onControl: settable callback for incoming control messages
|
|
@@ -32,12 +39,10 @@ export class WshFileTransfer {
|
|
|
32
39
|
/**
|
|
33
40
|
* Upload data to a remote path.
|
|
34
41
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* 4. Close the write side of the stream
|
|
40
|
-
* 5. Wait for EXIT message with status code
|
|
42
|
+
* Delegates to the underlying client's `upload()`, which sends the data
|
|
43
|
+
* as a sequence of `FileChunk` control messages over a `'file'`-kind
|
|
44
|
+
* channel — the single wire scheme for file transfer (see
|
|
45
|
+
* `WshClient.upload` in `client.mjs`).
|
|
41
46
|
*
|
|
42
47
|
* @param {Uint8Array | ArrayBuffer} data - File content to upload
|
|
43
48
|
* @param {string} remotePath - Destination path on the remote host
|
|
@@ -47,97 +52,31 @@ export class WshFileTransfer {
|
|
|
47
52
|
* @returns {Promise<{ success: boolean, bytesTransferred: number }>}
|
|
48
53
|
*/
|
|
49
54
|
async upload(data, remotePath, { onProgress, timeout = RESPONSE_TIMEOUT_MS } = {}) {
|
|
50
|
-
if (typeof this.#client.upload
|
|
51
|
-
|
|
52
|
-
onProgress: (value) => {
|
|
53
|
-
if (typeof value === 'number') {
|
|
54
|
-
onProgress?.({ sent: value, total: data.byteLength ?? data.length ?? 0 });
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
onProgress?.(value);
|
|
58
|
-
},
|
|
59
|
-
timeout,
|
|
60
|
-
});
|
|
61
|
-
const bytes = data instanceof Uint8Array ? data.byteLength : data.byteLength ?? data.length ?? 0;
|
|
62
|
-
return { success: true, bytesTransferred: bytes };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
66
|
-
if (!(bytes instanceof Uint8Array)) {
|
|
67
|
-
throw new TypeError('Data must be a Uint8Array or ArrayBuffer');
|
|
68
|
-
}
|
|
69
|
-
if (!remotePath || typeof remotePath !== 'string') {
|
|
70
|
-
throw new Error('remotePath is required');
|
|
55
|
+
if (typeof this.#client.upload !== 'function') {
|
|
56
|
+
throw new Error('WshFileTransfer requires a client exposing upload() (e.g. WshClient)');
|
|
71
57
|
}
|
|
72
58
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
59
|
+
await this.#client.upload(data, remotePath, {
|
|
60
|
+
onProgress: (value) => {
|
|
61
|
+
if (typeof value === 'number') {
|
|
62
|
+
onProgress?.({ sent: value, total: data.byteLength ?? data.length ?? 0 });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
onProgress?.(value);
|
|
66
|
+
},
|
|
67
|
+
timeout,
|
|
77
68
|
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
await this.#client.sendControl(openMsg);
|
|
82
|
-
|
|
83
|
-
// 2. Wait for OPEN_OK or OPEN_FAIL
|
|
84
|
-
const response = await this._waitForMessage(
|
|
85
|
-
(msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
|
|
86
|
-
timeout
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
if (response.type === MSG.OPEN_FAIL) {
|
|
90
|
-
throw new Error(`Upload rejected: ${response.reason || 'unknown reason'}`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const channelId = response.channel_id;
|
|
94
|
-
|
|
95
|
-
// 3. Open a dedicated data stream and write in chunks
|
|
96
|
-
const stream = await this.#client.openStream();
|
|
97
|
-
const writer = stream.writable.getWriter();
|
|
98
|
-
let sent = 0;
|
|
99
|
-
|
|
100
|
-
try {
|
|
101
|
-
while (sent < bytes.length) {
|
|
102
|
-
const end = Math.min(sent + CHUNK_SIZE, bytes.length);
|
|
103
|
-
const chunk = bytes.subarray(sent, end);
|
|
104
|
-
await writer.write(chunk);
|
|
105
|
-
sent = end;
|
|
106
|
-
try {
|
|
107
|
-
onProgress?.({ sent, total: bytes.length });
|
|
108
|
-
} catch { /* ignore callback errors */ }
|
|
109
|
-
}
|
|
110
|
-
} finally {
|
|
111
|
-
// 4. Close the write side to signal end of data
|
|
112
|
-
try {
|
|
113
|
-
await writer.close();
|
|
114
|
-
} catch {
|
|
115
|
-
// Stream may already be closed on error
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// 5. Wait for EXIT confirmation
|
|
120
|
-
const exitMsg = await this._waitForMessage(
|
|
121
|
-
(msg) => msg.type === MSG.EXIT && msg.channel_id === channelId,
|
|
122
|
-
timeout
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
const success = exitMsg.code === 0;
|
|
126
|
-
if (!success) {
|
|
127
|
-
throw new Error(`Upload failed with exit code ${exitMsg.code}`);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return { success, bytesTransferred: sent };
|
|
69
|
+
const bytes = data instanceof Uint8Array ? data.byteLength : data.byteLength ?? data.length ?? 0;
|
|
70
|
+
return { success: true, bytesTransferred: bytes };
|
|
131
71
|
}
|
|
132
72
|
|
|
133
73
|
/**
|
|
134
74
|
* Download a file from a remote path.
|
|
135
75
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* 4. Return complete file content as Uint8Array
|
|
76
|
+
* Delegates to the underlying client's `download()`, which reads the
|
|
77
|
+
* data as a sequence of `FileChunk` control messages over a
|
|
78
|
+
* `'file'`-kind channel — the single wire scheme for file transfer (see
|
|
79
|
+
* `WshClient.download` in `client.mjs`).
|
|
141
80
|
*
|
|
142
81
|
* @param {string} remotePath - File path on the remote host
|
|
143
82
|
* @param {object} [opts]
|
|
@@ -146,76 +85,10 @@ export class WshFileTransfer {
|
|
|
146
85
|
* @returns {Promise<Uint8Array>} File content
|
|
147
86
|
*/
|
|
148
87
|
async download(remotePath, { onProgress, timeout = RESPONSE_TIMEOUT_MS } = {}) {
|
|
149
|
-
if (typeof this.#client.download
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (!remotePath || typeof remotePath !== 'string') {
|
|
154
|
-
throw new Error('remotePath is required');
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// 1. Request a file download channel
|
|
158
|
-
const openMsg = open({
|
|
159
|
-
kind: CHANNEL_KIND.FILE,
|
|
160
|
-
command: 'download',
|
|
161
|
-
});
|
|
162
|
-
openMsg.path = remotePath;
|
|
163
|
-
|
|
164
|
-
await this.#client.sendControl(openMsg);
|
|
165
|
-
|
|
166
|
-
// 2. Wait for OPEN_OK or OPEN_FAIL
|
|
167
|
-
const response = await this._waitForMessage(
|
|
168
|
-
(msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
|
|
169
|
-
timeout
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
if (response.type === MSG.OPEN_FAIL) {
|
|
173
|
-
throw new Error(`Download rejected: ${response.reason || 'unknown reason'}`);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const channelId = response.channel_id;
|
|
177
|
-
const totalSize = response.size; // Server may include total file size
|
|
178
|
-
|
|
179
|
-
// 3. Open a dedicated data stream and read chunks
|
|
180
|
-
const stream = await this.#client.openStream();
|
|
181
|
-
const reader = stream.readable.getReader();
|
|
182
|
-
const chunks = [];
|
|
183
|
-
let received = 0;
|
|
184
|
-
|
|
185
|
-
try {
|
|
186
|
-
while (true) {
|
|
187
|
-
const { done, value } = await reader.read();
|
|
188
|
-
if (done) break;
|
|
189
|
-
|
|
190
|
-
chunks.push(value);
|
|
191
|
-
received += value.length;
|
|
192
|
-
|
|
193
|
-
try {
|
|
194
|
-
onProgress?.({ received, total: totalSize });
|
|
195
|
-
} catch { /* ignore callback errors */ }
|
|
196
|
-
}
|
|
197
|
-
} finally {
|
|
198
|
-
reader.releaseLock();
|
|
88
|
+
if (typeof this.#client.download !== 'function') {
|
|
89
|
+
throw new Error('WshFileTransfer requires a client exposing download() (e.g. WshClient)');
|
|
199
90
|
}
|
|
200
|
-
|
|
201
|
-
// 4. Wait for EXIT confirmation
|
|
202
|
-
try {
|
|
203
|
-
const exitMsg = await this._waitForMessage(
|
|
204
|
-
(msg) => msg.type === MSG.EXIT && msg.channel_id === channelId,
|
|
205
|
-
timeout
|
|
206
|
-
);
|
|
207
|
-
if (exitMsg.code !== 0) {
|
|
208
|
-
throw new Error(`Download failed with exit code ${exitMsg.code}`);
|
|
209
|
-
}
|
|
210
|
-
} catch (err) {
|
|
211
|
-
// If the stream closed cleanly and we got data, the EXIT may have
|
|
212
|
-
// already been consumed or the server may not send one for downloads.
|
|
213
|
-
// Only rethrow if we have no data.
|
|
214
|
-
if (received === 0) throw err;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Concatenate chunks into a single Uint8Array
|
|
218
|
-
return this._concatChunks(chunks, received);
|
|
91
|
+
return await this.#client.download(remotePath, { onProgress, timeout });
|
|
219
92
|
}
|
|
220
93
|
|
|
221
94
|
/**
|
package/src/index.d.ts
CHANGED
|
@@ -421,6 +421,7 @@ export function reversePeers(opts?: {
|
|
|
421
421
|
export function reverseConnect(opts?: {
|
|
422
422
|
targetFingerprint?: string;
|
|
423
423
|
username?: string;
|
|
424
|
+
fromFingerprint?: string;
|
|
424
425
|
}): WshMessage;
|
|
425
426
|
|
|
426
427
|
export function reverseAccept(opts?: {
|
|
@@ -441,6 +442,16 @@ export function reverseReject(opts?: {
|
|
|
441
442
|
reason?: string;
|
|
442
443
|
}): WshMessage;
|
|
443
444
|
|
|
445
|
+
export function relayForward(opts?: {
|
|
446
|
+
fromFingerprint?: string;
|
|
447
|
+
inner?: Uint8Array;
|
|
448
|
+
}): WshMessage;
|
|
449
|
+
|
|
450
|
+
/** Message types a relay may forward inside a RelayForward wrapper. */
|
|
451
|
+
export const RELAY_FORWARDABLE: ReadonlySet<number>;
|
|
452
|
+
|
|
453
|
+
export function isRelayForwardable(typeNum: number): boolean;
|
|
454
|
+
|
|
444
455
|
// -- Message constructors (gateway) --
|
|
445
456
|
|
|
446
457
|
export function openTcp(opts?: {
|
|
@@ -622,6 +633,37 @@ export function verifyChallenge(
|
|
|
622
633
|
channelBinding?: Uint8Array,
|
|
623
634
|
): Promise<boolean>;
|
|
624
635
|
|
|
636
|
+
/** Fields of a signed peer record (reverse-mode registration). See `signPeerRecord`. */
|
|
637
|
+
export interface WshPeerRecord {
|
|
638
|
+
username: string;
|
|
639
|
+
peerType?: string;
|
|
640
|
+
shellBackend?: string;
|
|
641
|
+
capabilities?: string[];
|
|
642
|
+
supportsAttach?: boolean;
|
|
643
|
+
supportsReplay?: boolean;
|
|
644
|
+
supportsEcho?: boolean;
|
|
645
|
+
supportsTermSync?: boolean;
|
|
646
|
+
/** The signing peer's own monotonic counter (current-time-millis in practice). */
|
|
647
|
+
seq: number;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Build the signed transcript for a peer record -- domain-separated from
|
|
652
|
+
* `buildTranscript`'s auth-challenge transcript even under the same key.
|
|
653
|
+
* @returns 32-byte SHA-256 hash
|
|
654
|
+
*/
|
|
655
|
+
export function buildPeerRecordTranscript(record: WshPeerRecord): Promise<Uint8Array>;
|
|
656
|
+
|
|
657
|
+
/** Client-side: sign a peer record with the identity key pair. */
|
|
658
|
+
export function signPeerRecord(
|
|
659
|
+
privateKey: CryptoKey,
|
|
660
|
+
publicKey: CryptoKey,
|
|
661
|
+
record: WshPeerRecord,
|
|
662
|
+
): Promise<{ signature: Uint8Array; publicKeyRaw: Uint8Array }>;
|
|
663
|
+
|
|
664
|
+
/** Verify a peer record's signature against the claimed record fields. */
|
|
665
|
+
export function verifyPeerRecord(publicKey: CryptoKey, signature: Uint8Array, record: WshPeerRecord): Promise<boolean>;
|
|
666
|
+
|
|
625
667
|
/**
|
|
626
668
|
* Compute the SHA-256 fingerprint of a raw public key.
|
|
627
669
|
* @returns hex-encoded fingerprint
|
|
@@ -791,6 +833,21 @@ export class WshSession {
|
|
|
791
833
|
/** Session capabilities advertised by OPEN_OK. */
|
|
792
834
|
readonly capabilities: string[];
|
|
793
835
|
|
|
836
|
+
/**
|
|
837
|
+
* The server-assigned session_id this channel belongs to, from OPEN_OK
|
|
838
|
+
* (clawser #48). `undefined` for channel kinds with no Attach/Resume-able
|
|
839
|
+
* session (e.g. file channels). Pass to `WshClient.attachSession`/
|
|
840
|
+
* `resumeSession` from another connection.
|
|
841
|
+
*/
|
|
842
|
+
readonly sessionId: string | undefined;
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* The session-scoped resume token minted at Open time, if any (clawser
|
|
846
|
+
* #48). Pass to `WshClient.resumeSession` from a later connection to
|
|
847
|
+
* reclaim this exact session.
|
|
848
|
+
*/
|
|
849
|
+
readonly resumeToken: Uint8Array | undefined;
|
|
850
|
+
|
|
794
851
|
/** Called when stdout/stderr data arrives. */
|
|
795
852
|
onData: ((data: Uint8Array) => void) | null;
|
|
796
853
|
|
|
@@ -832,6 +889,8 @@ export class WshSession {
|
|
|
832
889
|
opts?: {
|
|
833
890
|
dataMode?: 'stream' | 'virtual';
|
|
834
891
|
capabilities?: string[];
|
|
892
|
+
sessionId?: string;
|
|
893
|
+
resumeToken?: Uint8Array;
|
|
835
894
|
},
|
|
836
895
|
);
|
|
837
896
|
|
|
@@ -916,6 +975,21 @@ export interface WshOpenSessionOptions {
|
|
|
916
975
|
/** Options for WshClient.attachSession(). */
|
|
917
976
|
export interface WshAttachSessionOptions {
|
|
918
977
|
readOnly?: boolean;
|
|
978
|
+
/**
|
|
979
|
+
* Session-scoped token (clawser #48; optional). Omit for the common
|
|
980
|
+
* case of attaching via ownership or a `grantSessionAccess` grant --
|
|
981
|
+
* the server accepts EITHER a valid token OR the caller already
|
|
982
|
+
* owning/being ACL-granted access. Pass one (e.g. from
|
|
983
|
+
* `WshSession.resumeToken`) only if you have it.
|
|
984
|
+
*/
|
|
985
|
+
token?: Uint8Array;
|
|
986
|
+
timeout?: number;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** Options for WshClient.resumeSession(). */
|
|
990
|
+
export interface WshResumeSessionOptions {
|
|
991
|
+
/** Last sequence number this client already has (currently advisory). */
|
|
992
|
+
lastSeq?: number;
|
|
919
993
|
timeout?: number;
|
|
920
994
|
}
|
|
921
995
|
|
|
@@ -973,6 +1047,33 @@ export interface WshPeerInfo {
|
|
|
973
1047
|
supports_echo: boolean;
|
|
974
1048
|
supports_term_sync: boolean;
|
|
975
1049
|
last_seen: number | null;
|
|
1050
|
+
/** Raw 32-byte Ed25519 public key from the peer's signed record, if present. */
|
|
1051
|
+
public_key?: Uint8Array;
|
|
1052
|
+
/** The signing peer's own monotonic counter, from the signed record. */
|
|
1053
|
+
seq?: number;
|
|
1054
|
+
/** Raw Ed25519 signature over the peer record's transcript, if present. */
|
|
1055
|
+
record_signature?: Uint8Array;
|
|
1056
|
+
/**
|
|
1057
|
+
* Not a wire field -- computed client-side by `listPeers()` from
|
|
1058
|
+
* `public_key`/`seq`/`record_signature` via `verifyPeerRecord`. `true`
|
|
1059
|
+
* only if the signature verifies AND the key's fingerprint matches
|
|
1060
|
+
* `fingerprint`; `false` for missing/tampered/mismatched records.
|
|
1061
|
+
*/
|
|
1062
|
+
verified: boolean;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** Result from WshClient.initiateE2E(). */
|
|
1066
|
+
export interface WshE2EResult {
|
|
1067
|
+
/** Non-extractable AES-256-GCM key derived from the exchange. */
|
|
1068
|
+
sharedSecret: CryptoKey;
|
|
1069
|
+
/** The peer's ephemeral X25519 public key. */
|
|
1070
|
+
peerPublicKey: Uint8Array;
|
|
1071
|
+
/**
|
|
1072
|
+
* Whether hybrid X25519+ML-KEM-768 mode actually took effect --
|
|
1073
|
+
* `false` if `algorithm: 'X25519'` was requested, or if hybrid was
|
|
1074
|
+
* requested but the peer didn't support it (automatic fallback).
|
|
1075
|
+
*/
|
|
1076
|
+
hybrid: boolean;
|
|
976
1077
|
}
|
|
977
1078
|
|
|
978
1079
|
/**
|
|
@@ -991,6 +1092,14 @@ export class WshClient {
|
|
|
991
1092
|
/** Server-assigned session ID. */
|
|
992
1093
|
readonly sessionId: string | null;
|
|
993
1094
|
|
|
1095
|
+
/**
|
|
1096
|
+
* This connection's own AUTH-level token (from AUTH_OK), scoped to this
|
|
1097
|
+
* connection's auth session_id -- NOT a PTY/exec session token. See
|
|
1098
|
+
* `WshSession.resumeToken` for the per-session credential used by
|
|
1099
|
+
* `resumeSession`.
|
|
1100
|
+
*/
|
|
1101
|
+
readonly authToken: Uint8Array | null;
|
|
1102
|
+
|
|
994
1103
|
/** Read-only view of active sessions. */
|
|
995
1104
|
readonly sessions: Map<number, WshSession>;
|
|
996
1105
|
|
|
@@ -1049,13 +1158,38 @@ export class WshClient {
|
|
|
1049
1158
|
|
|
1050
1159
|
/**
|
|
1051
1160
|
* Attach to an existing remote session (collaborative or read-only).
|
|
1161
|
+
* Authorization is satisfied by EITHER `opts.token` OR the caller
|
|
1162
|
+
* already owning/being ACL-granted access to `targetSessionId`
|
|
1163
|
+
* server-side -- see `WshAttachSessionOptions.token`.
|
|
1052
1164
|
*/
|
|
1053
1165
|
attachSession(targetSessionId: string, opts?: WshAttachSessionOptions): Promise<WshMessage>;
|
|
1054
1166
|
|
|
1055
1167
|
/**
|
|
1056
|
-
* Resume a previously disconnected session.
|
|
1168
|
+
* Resume a previously disconnected session. Unlike `attachSession()`,
|
|
1169
|
+
* `token` is required and verified unconditionally server-side.
|
|
1170
|
+
*/
|
|
1171
|
+
resumeSession(targetSessionId: string, token: Uint8Array, opts?: WshResumeSessionOptions): Promise<WshMessage>;
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* Detach from a remote session, leaving it running server-side.
|
|
1175
|
+
*/
|
|
1176
|
+
detach(sessionId: string, timeout?: number): Promise<void>;
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* List sessions on the server this connection's key can see (a server
|
|
1180
|
+
* round trip — distinct from the purely local listSessions()).
|
|
1181
|
+
*/
|
|
1182
|
+
listRemoteSessions(timeout?: number): Promise<object[]>;
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Grant another principal access to a session this connection owns.
|
|
1057
1186
|
*/
|
|
1058
|
-
|
|
1187
|
+
grantSessionAccess(sessionId: string, principal: string, permissions?: string[]): Promise<void>;
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Revoke a previously granted principal's access to a session.
|
|
1191
|
+
*/
|
|
1192
|
+
revokeSessionAccess(sessionId: string, principal: string, reason?: string): Promise<void>;
|
|
1059
1193
|
|
|
1060
1194
|
/**
|
|
1061
1195
|
* Gracefully disconnect: close all sessions and the transport.
|
|
@@ -1095,6 +1229,26 @@ export class WshClient {
|
|
|
1095
1229
|
*/
|
|
1096
1230
|
sendRelayControl(msg: WshMessage): Promise<void>;
|
|
1097
1231
|
|
|
1232
|
+
/**
|
|
1233
|
+
* Initiate end-to-end encryption for a session: `'X25519'` (default,
|
|
1234
|
+
* classical ECDH) or `'X25519+ML-KEM-768'` (hybrid, with automatic
|
|
1235
|
+
* fallback to classical if the peer doesn't support it). Experimental
|
|
1236
|
+
* -- the derived key is not yet wired to any actual encryption.
|
|
1237
|
+
*/
|
|
1238
|
+
initiateE2E(sessionId: string, algorithm?: 'X25519' | 'X25519+ML-KEM-768', timeout?: number): Promise<WshE2EResult>;
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Mark a peer fingerprint as an accepted reverse-connect bridge partner.
|
|
1242
|
+
* Only RelayForward-wrapped messages whose from_fingerprint is trusted
|
|
1243
|
+
* this way are unwrapped and delivered via onRelayMessage.
|
|
1244
|
+
*/
|
|
1245
|
+
trustRelayPeer(fingerprint: string): void;
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Stop trusting a peer as a relay-forward bridge partner.
|
|
1249
|
+
*/
|
|
1250
|
+
untrustRelayPeer(fingerprint: string): void;
|
|
1251
|
+
|
|
1098
1252
|
/**
|
|
1099
1253
|
* Upload a blob to a remote path.
|
|
1100
1254
|
*/
|
package/src/index.mjs
CHANGED
|
@@ -17,6 +17,7 @@ export {
|
|
|
17
17
|
presence, controlChanged, metrics,
|
|
18
18
|
mcpDiscover, mcpTools, mcpCall, mcpResult,
|
|
19
19
|
reverseRegister, reverseList, reversePeers, reverseConnect, reverseAccept, reverseReject,
|
|
20
|
+
relayForward, RELAY_FORWARDABLE, isRelayForwardable,
|
|
20
21
|
openTcp, openUdp, resolveDns, gatewayOk, gatewayFail, gatewayClose,
|
|
21
22
|
inboundOpen, inboundAccept, inboundReject, dnsResult,
|
|
22
23
|
listenRequest, listenOk, listenFail, listenClose, gatewayData,
|
|
@@ -44,6 +45,7 @@ export {
|
|
|
44
45
|
generateKeyPair, exportPublicKeyRaw, exportPublicKeySSH,
|
|
45
46
|
importPublicKeyRaw, exportPrivateKeyPKCS8, importPrivateKeyPKCS8,
|
|
46
47
|
sign, verify, buildTranscript, signChallenge, verifyChallenge,
|
|
48
|
+
buildPeerRecordTranscript, signPeerRecord, verifyPeerRecord,
|
|
47
49
|
fingerprint, shortFingerprint, generateNonce,
|
|
48
50
|
parseSSHPublicKey, extractRawFromSSHWire, base64Decode,
|
|
49
51
|
} from './auth.mjs';
|
|
@@ -52,6 +54,17 @@ export {
|
|
|
52
54
|
export { WshTransport, WebTransportTransport, dispatchSerially, SerialQueue } from './transport.mjs';
|
|
53
55
|
export { WebSocketTransport, WS_FRAME_TYPE } from './transport-ws.mjs';
|
|
54
56
|
|
|
57
|
+
// QMux (QUIC-v1 frames over an ordered byte stream, draft-ietf-quic-qmux-02)
|
|
58
|
+
// -- the wire multiplexing layer WebSocketTransport speaks. Exposed so an
|
|
59
|
+
// alternate server implementation (not just the Rust wsh-server, which
|
|
60
|
+
// vendors its own port) can speak the same framing without reimplementing
|
|
61
|
+
// it -- see clawser's tools/wsh-server.mjs for exactly this use case.
|
|
62
|
+
export { QMuxConnection, DEFAULTS as QMUX_DEFAULTS } from './qmux-connection.mjs';
|
|
63
|
+
export {
|
|
64
|
+
ERROR_CODE as QMUX_ERROR_CODE, STREAM_INITIATOR as QMUX_STREAM_INITIATOR,
|
|
65
|
+
firstBidiStreamId, nextBidiStreamId, isClientInitiated, isBidirectional,
|
|
66
|
+
} from './qmux.mjs';
|
|
67
|
+
|
|
55
68
|
// Session + Client
|
|
56
69
|
export { WshSession } from './session.mjs';
|
|
57
70
|
export { WshVirtualSessionBackend, normalizeSessionData } from './virtual-session.mjs';
|
package/src/messages.gen.mjs
CHANGED
|
@@ -62,6 +62,7 @@ export const MSG = Object.freeze({
|
|
|
62
62
|
REVERSE_CONNECT: 0x53,
|
|
63
63
|
REVERSE_ACCEPT: 0x54,
|
|
64
64
|
REVERSE_REJECT: 0x55,
|
|
65
|
+
RELAY_FORWARD: 0x56,
|
|
65
66
|
|
|
66
67
|
// Session
|
|
67
68
|
SESSION_LIST: 0x5f,
|
|
@@ -246,14 +247,11 @@ export function open({ kind, command, cols, rows, env } = {}) {
|
|
|
246
247
|
return msg;
|
|
247
248
|
}
|
|
248
249
|
|
|
249
|
-
export function openOk({ channelId, streamIds = [], dataMode = "stream", capabilities = [] } = {}) {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
data_mode: dataMode,
|
|
255
|
-
capabilities,
|
|
256
|
-
};
|
|
250
|
+
export function openOk({ channelId, streamIds = [], dataMode = "stream", capabilities = [], sessionId, token } = {}) {
|
|
251
|
+
const msg = { type: MSG.OPEN_OK, channel_id: channelId, stream_ids: streamIds, data_mode: dataMode, capabilities };
|
|
252
|
+
if (sessionId !== undefined) msg.session_id = sessionId;
|
|
253
|
+
if (token !== undefined) msg.token = token;
|
|
254
|
+
return msg;
|
|
257
255
|
}
|
|
258
256
|
|
|
259
257
|
export function openFail({ reason } = {}) {
|
|
@@ -326,7 +324,8 @@ export function pong({ id } = {}) {
|
|
|
326
324
|
}
|
|
327
325
|
|
|
328
326
|
export function attach({ sessionId, token, mode = "control", deviceLabel } = {}) {
|
|
329
|
-
const msg = { type: MSG.ATTACH, session_id: sessionId,
|
|
327
|
+
const msg = { type: MSG.ATTACH, session_id: sessionId, mode };
|
|
328
|
+
if (token !== undefined) msg.token = token;
|
|
330
329
|
if (deviceLabel !== undefined) msg.device_label = deviceLabel;
|
|
331
330
|
return msg;
|
|
332
331
|
}
|
|
@@ -457,7 +456,7 @@ export function mcpResult({ result } = {}) {
|
|
|
457
456
|
};
|
|
458
457
|
}
|
|
459
458
|
|
|
460
|
-
export function reverseRegister({ username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false, publicKey } = {}) {
|
|
459
|
+
export function reverseRegister({ username, capabilities = [], peerType = "host", shellBackend = "pty", supportsAttach = false, supportsReplay = false, supportsEcho = false, supportsTermSync = false, publicKey, seq, recordSignature } = {}) {
|
|
461
460
|
return {
|
|
462
461
|
type: MSG.REVERSE_REGISTER,
|
|
463
462
|
username,
|
|
@@ -469,6 +468,8 @@ export function reverseRegister({ username, capabilities = [], peerType = "host"
|
|
|
469
468
|
supports_echo: supportsEcho,
|
|
470
469
|
supports_term_sync: supportsTermSync,
|
|
471
470
|
public_key: publicKey,
|
|
471
|
+
seq,
|
|
472
|
+
record_signature: recordSignature,
|
|
472
473
|
};
|
|
473
474
|
}
|
|
474
475
|
|
|
@@ -483,11 +484,12 @@ export function reversePeers({ peers } = {}) {
|
|
|
483
484
|
};
|
|
484
485
|
}
|
|
485
486
|
|
|
486
|
-
export function reverseConnect({ targetFingerprint, username } = {}) {
|
|
487
|
+
export function reverseConnect({ targetFingerprint, username, fromFingerprint } = {}) {
|
|
487
488
|
return {
|
|
488
489
|
type: MSG.REVERSE_CONNECT,
|
|
489
490
|
target_fingerprint: targetFingerprint,
|
|
490
491
|
username,
|
|
492
|
+
from_fingerprint: fromFingerprint,
|
|
491
493
|
};
|
|
492
494
|
}
|
|
493
495
|
|
|
@@ -515,6 +517,14 @@ export function reverseReject({ targetFingerprint, username, reason } = {}) {
|
|
|
515
517
|
};
|
|
516
518
|
}
|
|
517
519
|
|
|
520
|
+
export function relayForward({ fromFingerprint, inner } = {}) {
|
|
521
|
+
return {
|
|
522
|
+
type: MSG.RELAY_FORWARD,
|
|
523
|
+
from_fingerprint: fromFingerprint,
|
|
524
|
+
inner,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
518
528
|
export function sessionList({ sessions } = {}) {
|
|
519
529
|
return {
|
|
520
530
|
type: MSG.SESSION_LIST,
|
|
@@ -759,13 +769,12 @@ export function copilotDetach({ sessionId, reason } = {}) {
|
|
|
759
769
|
return msg;
|
|
760
770
|
}
|
|
761
771
|
|
|
762
|
-
export function keyExchange({ algorithm, publicKey, sessionId } = {}) {
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
};
|
|
772
|
+
export function keyExchange({ algorithm, publicKey, sessionId, kemPublicKey, kemCiphertext } = {}) {
|
|
773
|
+
const msg = { type: MSG.KEY_EXCHANGE, algorithm, session_id: sessionId };
|
|
774
|
+
if (publicKey !== undefined) msg.public_key = publicKey;
|
|
775
|
+
if (kemPublicKey !== undefined) msg.kem_public_key = kemPublicKey;
|
|
776
|
+
if (kemCiphertext !== undefined) msg.kem_ciphertext = kemCiphertext;
|
|
777
|
+
return msg;
|
|
769
778
|
}
|
|
770
779
|
|
|
771
780
|
export function encryptedFrame({ nonce, ciphertext, sessionId } = {}) {
|
|
@@ -859,13 +868,14 @@ export function fileResult({ channelId, success, metadata = {}, errorMessage } =
|
|
|
859
868
|
return msg;
|
|
860
869
|
}
|
|
861
870
|
|
|
862
|
-
export function fileChunk({ channelId, offset, data, isFinal } = {}) {
|
|
871
|
+
export function fileChunk({ channelId, offset, data, isFinal, totalSize } = {}) {
|
|
863
872
|
return {
|
|
864
873
|
type: MSG.FILE_CHUNK,
|
|
865
874
|
channel_id: channelId,
|
|
866
875
|
offset,
|
|
867
876
|
data,
|
|
868
877
|
is_final: isFinal,
|
|
878
|
+
total_size: totalSize,
|
|
869
879
|
};
|
|
870
880
|
}
|
|
871
881
|
|
|
@@ -903,6 +913,50 @@ export function terminalConfig({ channelId, frontend, options = {} } = {}) {
|
|
|
903
913
|
};
|
|
904
914
|
}
|
|
905
915
|
|
|
916
|
+
// ── Relay forwarding ─────────────────────────────────────────────────
|
|
917
|
+
|
|
918
|
+
// Message types a relay may forward inside a RelayForward wrapper.
|
|
919
|
+
export const RELAY_FORWARDABLE = new Set([
|
|
920
|
+
MSG.OPEN,
|
|
921
|
+
MSG.OPEN_OK,
|
|
922
|
+
MSG.OPEN_FAIL,
|
|
923
|
+
MSG.CLOSE,
|
|
924
|
+
MSG.EXIT,
|
|
925
|
+
MSG.RESIZE,
|
|
926
|
+
MSG.SIGNAL,
|
|
927
|
+
MSG.SESSION_DATA,
|
|
928
|
+
MSG.GATEWAY_DATA,
|
|
929
|
+
MSG.GATEWAY_OK,
|
|
930
|
+
MSG.GATEWAY_FAIL,
|
|
931
|
+
MSG.GATEWAY_CLOSE,
|
|
932
|
+
MSG.MCP_DISCOVER,
|
|
933
|
+
MSG.MCP_TOOLS,
|
|
934
|
+
MSG.MCP_CALL,
|
|
935
|
+
MSG.MCP_RESULT,
|
|
936
|
+
MSG.REVERSE_ACCEPT,
|
|
937
|
+
MSG.REVERSE_REJECT,
|
|
938
|
+
MSG.GUEST_JOIN,
|
|
939
|
+
MSG.GUEST_REVOKE,
|
|
940
|
+
MSG.COPILOT_ATTACH,
|
|
941
|
+
MSG.COPILOT_DETACH,
|
|
942
|
+
MSG.FILE_OP,
|
|
943
|
+
MSG.FILE_RESULT,
|
|
944
|
+
MSG.FILE_CHUNK,
|
|
945
|
+
MSG.POLICY_EVAL,
|
|
946
|
+
MSG.ECHO_ACK,
|
|
947
|
+
MSG.ECHO_STATE,
|
|
948
|
+
MSG.TERM_SYNC,
|
|
949
|
+
MSG.TERM_DIFF,
|
|
950
|
+
]);
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* @param {number} typeNum
|
|
954
|
+
* @returns {boolean}
|
|
955
|
+
*/
|
|
956
|
+
export function isRelayForwardable(typeNum) {
|
|
957
|
+
return RELAY_FORWARDABLE.has(typeNum);
|
|
958
|
+
}
|
|
959
|
+
|
|
906
960
|
// ── Utility ───────────────────────────────────────────────────────────
|
|
907
961
|
|
|
908
962
|
/**
|
package/src/messages.mjs
CHANGED