ai-remote 0.1.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/README.md +50 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +7 -0
- package/dist/protocols/index.js +35 -0
- package/dist/protocols/index.js.map +7 -0
- package/dist/protocols/rdp/buffer.js +170 -0
- package/dist/protocols/rdp/buffer.js.map +7 -0
- package/dist/protocols/rdp/caps.js +260 -0
- package/dist/protocols/rdp/caps.js.map +7 -0
- package/dist/protocols/rdp/cert.js +135 -0
- package/dist/protocols/rdp/cert.js.map +7 -0
- package/dist/protocols/rdp/client.js +1068 -0
- package/dist/protocols/rdp/client.js.map +7 -0
- package/dist/protocols/rdp/cliprdr.js +240 -0
- package/dist/protocols/rdp/cliprdr.js.map +7 -0
- package/dist/protocols/rdp/credssp.js +254 -0
- package/dist/protocols/rdp/credssp.js.map +7 -0
- package/dist/protocols/rdp/crypto.js +369 -0
- package/dist/protocols/rdp/crypto.js.map +7 -0
- package/dist/protocols/rdp/display.js +589 -0
- package/dist/protocols/rdp/display.js.map +7 -0
- package/dist/protocols/rdp/gcc.js +132 -0
- package/dist/protocols/rdp/gcc.js.map +7 -0
- package/dist/protocols/rdp/keymap.js +132 -0
- package/dist/protocols/rdp/keymap.js.map +7 -0
- package/dist/protocols/rdp/mcs.js +223 -0
- package/dist/protocols/rdp/mcs.js.map +7 -0
- package/dist/protocols/rdp/ntlm.js +305 -0
- package/dist/protocols/rdp/ntlm.js.map +7 -0
- package/dist/protocols/rdp/pdu.js +444 -0
- package/dist/protocols/rdp/pdu.js.map +7 -0
- package/dist/protocols/rdp/rail.js +383 -0
- package/dist/protocols/rdp/rail.js.map +7 -0
- package/dist/protocols/rdp/rle.js +276 -0
- package/dist/protocols/rdp/rle.js.map +7 -0
- package/dist/protocols/rdp/sec.js +156 -0
- package/dist/protocols/rdp/sec.js.map +7 -0
- package/dist/protocols/rdp/session.js +307 -0
- package/dist/protocols/rdp/session.js.map +7 -0
- package/dist/protocols/rdp/tls.js +354 -0
- package/dist/protocols/rdp/tls.js.map +7 -0
- package/dist/protocols/rdp/vchannel.js +59 -0
- package/dist/protocols/rdp/vchannel.js.map +7 -0
- package/dist/protocols/rdp/x224.js +110 -0
- package/dist/protocols/rdp/x224.js.map +7 -0
- package/dist/protocols/ssh/kex.js +177 -0
- package/dist/protocols/ssh/kex.js.map +7 -0
- package/dist/protocols/ssh/messages.js +55 -0
- package/dist/protocols/ssh/messages.js.map +7 -0
- package/dist/protocols/ssh/session.js +608 -0
- package/dist/protocols/ssh/session.js.map +7 -0
- package/dist/protocols/ssh/terminal.js +191 -0
- package/dist/protocols/ssh/terminal.js.map +7 -0
- package/dist/protocols/ssh/transport.js +611 -0
- package/dist/protocols/ssh/transport.js.map +7 -0
- package/dist/protocols/ssh/wire.js +189 -0
- package/dist/protocols/ssh/wire.js.map +7 -0
- package/dist/protocols/types.js +1 -0
- package/dist/protocols/types.js.map +7 -0
- package/dist/protocols/vnc/input.js +16 -0
- package/dist/protocols/vnc/input.js.map +7 -0
- package/dist/protocols/vnc/session.js +259 -0
- package/dist/protocols/vnc/session.js.map +7 -0
- package/dist/shared/connection.js +114 -0
- package/dist/shared/connection.js.map +7 -0
- package/dist/shared/device.js +1 -0
- package/dist/shared/device.js.map +7 -0
- package/dist/shared/hosts.js +133 -0
- package/dist/shared/hosts.js.map +7 -0
- package/dist/shared/icons.js +42 -0
- package/dist/shared/icons.js.map +7 -0
- package/dist/shared/protocol.js +40 -0
- package/dist/shared/protocol.js.map +7 -0
- package/dist/shared/signals.js +42 -0
- package/dist/shared/signals.js.map +7 -0
- package/package.json +82 -0
- package/src/index.ts +14 -0
- package/src/protocols/index.ts +72 -0
- package/src/protocols/rdp/buffer.ts +204 -0
- package/src/protocols/rdp/caps.ts +304 -0
- package/src/protocols/rdp/cert.ts +190 -0
- package/src/protocols/rdp/client.ts +1363 -0
- package/src/protocols/rdp/cliprdr.ts +344 -0
- package/src/protocols/rdp/credssp.ts +319 -0
- package/src/protocols/rdp/crypto.ts +416 -0
- package/src/protocols/rdp/display.ts +750 -0
- package/src/protocols/rdp/gcc.ts +162 -0
- package/src/protocols/rdp/keymap.ts +68 -0
- package/src/protocols/rdp/mcs.ts +283 -0
- package/src/protocols/rdp/ntlm.ts +380 -0
- package/src/protocols/rdp/pdu.ts +497 -0
- package/src/protocols/rdp/rail.ts +480 -0
- package/src/protocols/rdp/rle.ts +343 -0
- package/src/protocols/rdp/sec.ts +184 -0
- package/src/protocols/rdp/session.ts +393 -0
- package/src/protocols/rdp/tls.ts +452 -0
- package/src/protocols/rdp/vchannel.ts +89 -0
- package/src/protocols/rdp/x224.ts +139 -0
- package/src/protocols/ssh/kex.ts +227 -0
- package/src/protocols/ssh/messages.ts +54 -0
- package/src/protocols/ssh/session.ts +878 -0
- package/src/protocols/ssh/terminal.ts +242 -0
- package/src/protocols/ssh/transport.ts +732 -0
- package/src/protocols/ssh/wire.ts +220 -0
- package/src/protocols/types.ts +146 -0
- package/src/protocols/vnc/input.ts +13 -0
- package/src/protocols/vnc/novnc.d.ts +75 -0
- package/src/protocols/vnc/session.ts +367 -0
- package/src/shared/connection.ts +206 -0
- package/src/shared/device.ts +28 -0
- package/src/shared/hosts.ts +240 -0
- package/src/shared/icons.ts +49 -0
- package/src/shared/protocol.ts +55 -0
- package/src/shared/signals.ts +67 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import { MSG, DISCONNECT_REASON } from "./messages";
|
|
2
|
+
import {
|
|
3
|
+
SshReader,
|
|
4
|
+
SshWriter,
|
|
5
|
+
concatBytes,
|
|
6
|
+
encodeUtf8
|
|
7
|
+
} from "./wire";
|
|
8
|
+
import {
|
|
9
|
+
createKexClient,
|
|
10
|
+
deriveKey,
|
|
11
|
+
exchangeHash,
|
|
12
|
+
hostKeyFingerprint,
|
|
13
|
+
hostKeyType,
|
|
14
|
+
negotiableAlgorithms,
|
|
15
|
+
verifyHostKeySignature
|
|
16
|
+
} from "./kex";
|
|
17
|
+
import {
|
|
18
|
+
GATEWAY_KEEPALIVE_SIGNAL,
|
|
19
|
+
GATEWAY_READY_SIGNAL,
|
|
20
|
+
KEEPALIVE_INTERVAL_MS
|
|
21
|
+
} from "../../shared/signals";
|
|
22
|
+
const CLIENT_VERSION = "SSH-2.0-CloudflareGateway_1.0";
|
|
23
|
+
const CIPHERS = {
|
|
24
|
+
"aes256-gcm@openssh.com": { keyLength: 32 },
|
|
25
|
+
"aes128-gcm@openssh.com": { keyLength: 16 }
|
|
26
|
+
};
|
|
27
|
+
const CIPHER_NAMES = Object.keys(CIPHERS);
|
|
28
|
+
const MAC_NAMES = ["hmac-sha2-256", "hmac-sha2-512"];
|
|
29
|
+
const COMPRESSION_NAMES = ["none"];
|
|
30
|
+
const GCM_BLOCK = 16;
|
|
31
|
+
const GCM_TAG_BYTES = 16;
|
|
32
|
+
const GCM_IV_BYTES = 12;
|
|
33
|
+
const PLAIN_BLOCK = 8;
|
|
34
|
+
const MIN_PADDING = 4;
|
|
35
|
+
const MAX_PACKET_BYTES = 262144;
|
|
36
|
+
const VERSION_LINE_LIMIT = 8192;
|
|
37
|
+
const subtle = globalThis.crypto?.subtle;
|
|
38
|
+
function negotiate(clientNames, serverNames) {
|
|
39
|
+
return clientNames.find((name) => serverNames.includes(name)) || null;
|
|
40
|
+
}
|
|
41
|
+
class GcmCipher {
|
|
42
|
+
key;
|
|
43
|
+
fixed;
|
|
44
|
+
counter;
|
|
45
|
+
constructor(key, iv) {
|
|
46
|
+
this.key = key;
|
|
47
|
+
this.fixed = iv.slice(0, 4);
|
|
48
|
+
this.counter = iv.slice(4, 12);
|
|
49
|
+
}
|
|
50
|
+
static async create(keyBytes, iv) {
|
|
51
|
+
const key = await subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
52
|
+
return new GcmCipher(key, iv);
|
|
53
|
+
}
|
|
54
|
+
/** The 12-byte nonce for this packet, then step the 64-bit counter. */
|
|
55
|
+
nextIv() {
|
|
56
|
+
const iv = concatBytes(this.fixed, this.counter);
|
|
57
|
+
for (let index = this.counter.length - 1; index >= 0; index--) {
|
|
58
|
+
this.counter[index] = this.counter[index] + 1 & 255;
|
|
59
|
+
if (this.counter[index] !== 0) break;
|
|
60
|
+
}
|
|
61
|
+
return iv;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
class SshTransport extends EventTarget {
|
|
65
|
+
url;
|
|
66
|
+
verifyHost;
|
|
67
|
+
log;
|
|
68
|
+
socket;
|
|
69
|
+
closed;
|
|
70
|
+
buffer;
|
|
71
|
+
serverVersion;
|
|
72
|
+
greeting;
|
|
73
|
+
sendSequence;
|
|
74
|
+
receiveSequence;
|
|
75
|
+
outgoing;
|
|
76
|
+
incoming;
|
|
77
|
+
sessionId;
|
|
78
|
+
kex;
|
|
79
|
+
kexQueue;
|
|
80
|
+
keepaliveTimer;
|
|
81
|
+
bytesSent;
|
|
82
|
+
bytesReceived;
|
|
83
|
+
encryptChain;
|
|
84
|
+
newKeysBarrier;
|
|
85
|
+
releaseNewKeys;
|
|
86
|
+
pendingServerKexInit;
|
|
87
|
+
established;
|
|
88
|
+
/**
|
|
89
|
+
* @param {string} url gateway WebSocket URL
|
|
90
|
+
* @param {object} options
|
|
91
|
+
* @param {(info: object) => Promise<boolean>} options.verifyHost decides
|
|
92
|
+
* whether an unknown or changed host key may be used.
|
|
93
|
+
*/
|
|
94
|
+
constructor(url, { verifyHost, log } = {}) {
|
|
95
|
+
super();
|
|
96
|
+
this.url = url;
|
|
97
|
+
this.verifyHost = verifyHost || (() => true);
|
|
98
|
+
this.log = log || (() => {
|
|
99
|
+
});
|
|
100
|
+
this.socket = null;
|
|
101
|
+
this.closed = false;
|
|
102
|
+
this.buffer = new Uint8Array(0);
|
|
103
|
+
this.serverVersion = "";
|
|
104
|
+
this.greeting = [];
|
|
105
|
+
this.sendSequence = 0;
|
|
106
|
+
this.receiveSequence = 0;
|
|
107
|
+
this.outgoing = null;
|
|
108
|
+
this.incoming = null;
|
|
109
|
+
this.sessionId = null;
|
|
110
|
+
this.kex = null;
|
|
111
|
+
this.kexQueue = [];
|
|
112
|
+
this.keepaliveTimer = null;
|
|
113
|
+
this.bytesSent = 0;
|
|
114
|
+
this.bytesReceived = 0;
|
|
115
|
+
}
|
|
116
|
+
// --- connection ----------------------------------------------------------
|
|
117
|
+
connect() {
|
|
118
|
+
this.socket = new WebSocket(this.url);
|
|
119
|
+
this.socket.binaryType = "arraybuffer";
|
|
120
|
+
this.socket.addEventListener("open", () => {
|
|
121
|
+
this.socket.send(GATEWAY_READY_SIGNAL);
|
|
122
|
+
this.#write(encodeUtf8(`${CLIENT_VERSION}\r
|
|
123
|
+
`));
|
|
124
|
+
this.keepaliveTimer = setInterval(() => {
|
|
125
|
+
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
|
126
|
+
this.socket.send(GATEWAY_KEEPALIVE_SIGNAL);
|
|
127
|
+
if (this.sessionId) {
|
|
128
|
+
try {
|
|
129
|
+
this.#sendPacket(new SshWriter(64).u8(MSG.GLOBAL_REQUEST).string("keepalive@openssh.com").u8(1).take());
|
|
130
|
+
} catch {
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
134
|
+
});
|
|
135
|
+
this.socket.addEventListener("message", (event) => {
|
|
136
|
+
if (typeof event.data === "string") return;
|
|
137
|
+
this.#receive(new Uint8Array(event.data));
|
|
138
|
+
});
|
|
139
|
+
this.socket.addEventListener("close", (event) => {
|
|
140
|
+
this.#finish({
|
|
141
|
+
clean: event.code === 1e3,
|
|
142
|
+
code: event.code,
|
|
143
|
+
message: event.reason || (event.code === 1006 ? "The gateway rejected or could not reach this SSH target. Confirm that SSH is published for this host." : "")
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
this.socket.addEventListener("error", () => {
|
|
147
|
+
this.log("The SSH gateway WebSocket reported a transport error; waiting for its close reason.");
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
disconnect(message = "Client disconnected") {
|
|
151
|
+
if (this.closed) return;
|
|
152
|
+
try {
|
|
153
|
+
const payload = new SshWriter(64).u8(MSG.DISCONNECT).u32(11).string(message).string("").take();
|
|
154
|
+
this.#sendPacket(payload);
|
|
155
|
+
} catch {
|
|
156
|
+
}
|
|
157
|
+
const socket = this.socket;
|
|
158
|
+
void Promise.resolve(this.encryptChain).finally(() => {
|
|
159
|
+
try {
|
|
160
|
+
socket?.close(1e3, "Client disconnected");
|
|
161
|
+
} catch {
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
this.#finish({ clean: true, code: 1e3, message: "" });
|
|
165
|
+
}
|
|
166
|
+
#finish(detail) {
|
|
167
|
+
if (this.closed) return;
|
|
168
|
+
this.closed = true;
|
|
169
|
+
clearInterval(this.keepaliveTimer);
|
|
170
|
+
this.keepaliveTimer = null;
|
|
171
|
+
this.dispatchEvent(new CustomEvent("close", { detail }));
|
|
172
|
+
}
|
|
173
|
+
fail(error) {
|
|
174
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
175
|
+
if (this.closed) return;
|
|
176
|
+
this.dispatchEvent(new CustomEvent("error", { detail: { message } }));
|
|
177
|
+
this.#finish({ clean: false, code: 4e3, message });
|
|
178
|
+
try {
|
|
179
|
+
this.socket?.close(4e3, message.slice(0, 100));
|
|
180
|
+
} catch {
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
#write(bytes) {
|
|
184
|
+
if (this.socket?.readyState !== WebSocket.OPEN) return;
|
|
185
|
+
this.bytesSent += bytes.length;
|
|
186
|
+
this.socket.send(bytes);
|
|
187
|
+
}
|
|
188
|
+
// --- framing -------------------------------------------------------------
|
|
189
|
+
#receive(chunk) {
|
|
190
|
+
this.bytesReceived += chunk.length;
|
|
191
|
+
this.buffer = this.buffer.length ? concatBytes(this.buffer, chunk) : chunk;
|
|
192
|
+
this.drainChain = (this.drainChain || Promise.resolve()).then(() => this.#drain()).catch((error) => this.fail(error));
|
|
193
|
+
}
|
|
194
|
+
async #drain() {
|
|
195
|
+
if (this.closed) return;
|
|
196
|
+
if (!this.serverVersion && !this.#readVersion()) return;
|
|
197
|
+
while (!this.closed) {
|
|
198
|
+
if (this.newKeysBarrier) {
|
|
199
|
+
const barrier = this.newKeysBarrier;
|
|
200
|
+
this.newKeysBarrier = null;
|
|
201
|
+
await barrier;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (!this.incoming) {
|
|
205
|
+
const payload = this.#readPlainPacket();
|
|
206
|
+
if (!payload) return;
|
|
207
|
+
this.#dispatchPacket(payload);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const frame = this.#takeEncryptedFrame();
|
|
211
|
+
if (!frame) return;
|
|
212
|
+
this.#dispatchPacket(await this.#decrypt(frame));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The host may print any number of lines before its version line, and RFC
|
|
217
|
+
* 4253 says to show them: this is where a "banned by fail2ban" notice or a
|
|
218
|
+
* legal banner arrives.
|
|
219
|
+
*/
|
|
220
|
+
#readVersion() {
|
|
221
|
+
while (true) {
|
|
222
|
+
const newline = this.buffer.indexOf(10);
|
|
223
|
+
if (newline === -1) {
|
|
224
|
+
if (this.buffer.length > VERSION_LINE_LIMIT) {
|
|
225
|
+
throw new Error("SSH: the host sent no version line.");
|
|
226
|
+
}
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
let end = newline;
|
|
230
|
+
if (end > 0 && this.buffer[end - 1] === 13) end--;
|
|
231
|
+
const line = new TextDecoder().decode(this.buffer.subarray(0, end));
|
|
232
|
+
this.buffer = this.buffer.subarray(newline + 1);
|
|
233
|
+
if (!line.startsWith("SSH-")) {
|
|
234
|
+
if (line.trim()) this.greeting.push(line);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (!line.startsWith("SSH-2.0-") && !line.startsWith("SSH-1.99-")) {
|
|
238
|
+
throw new Error(`SSH: this host speaks ${line.split("-").slice(0, 2).join("-")}, which is too old.`);
|
|
239
|
+
}
|
|
240
|
+
this.serverVersion = line;
|
|
241
|
+
this.log("version", { server: line, greeting: this.greeting });
|
|
242
|
+
this.dispatchEvent(new CustomEvent("greeting", { detail: { lines: this.greeting.slice() } }));
|
|
243
|
+
void this.#startKex();
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/** A packet from before the keys exist. Returns null when more bytes are due. */
|
|
248
|
+
#readPlainPacket() {
|
|
249
|
+
if (this.buffer.length < 5) return null;
|
|
250
|
+
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
|
|
251
|
+
const packetLength = view.getUint32(0, false);
|
|
252
|
+
if (packetLength < 8 || packetLength > MAX_PACKET_BYTES) {
|
|
253
|
+
throw new Error("SSH: the host sent a packet of an impossible size.");
|
|
254
|
+
}
|
|
255
|
+
if (this.buffer.length < 4 + packetLength) return null;
|
|
256
|
+
const paddingLength = this.buffer[4];
|
|
257
|
+
if (paddingLength + 1 > packetLength) throw new Error("SSH: a packet claimed more padding than it had.");
|
|
258
|
+
const payload = this.buffer.slice(5, 4 + packetLength - paddingLength);
|
|
259
|
+
this.buffer = this.buffer.subarray(4 + packetLength);
|
|
260
|
+
this.receiveSequence = this.receiveSequence + 1 >>> 0;
|
|
261
|
+
return payload;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* With AES-GCM the length is authenticated but not encrypted, so the frame
|
|
265
|
+
* can be measured before anything is decrypted (RFC 5647).
|
|
266
|
+
*/
|
|
267
|
+
#takeEncryptedFrame() {
|
|
268
|
+
if (this.buffer.length < 4) return null;
|
|
269
|
+
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
|
|
270
|
+
const packetLength = view.getUint32(0, false);
|
|
271
|
+
if (packetLength < GCM_BLOCK || packetLength % GCM_BLOCK !== 0 || packetLength > MAX_PACKET_BYTES) {
|
|
272
|
+
throw new Error("SSH: the host sent a packet of an impossible size.");
|
|
273
|
+
}
|
|
274
|
+
const total = 4 + packetLength + GCM_TAG_BYTES;
|
|
275
|
+
if (this.buffer.length < total) return null;
|
|
276
|
+
const frame = this.buffer.slice(0, total);
|
|
277
|
+
this.buffer = this.buffer.subarray(total);
|
|
278
|
+
return frame;
|
|
279
|
+
}
|
|
280
|
+
async #decrypt(frame) {
|
|
281
|
+
let plain;
|
|
282
|
+
try {
|
|
283
|
+
plain = new Uint8Array(await subtle.decrypt(
|
|
284
|
+
{
|
|
285
|
+
name: "AES-GCM",
|
|
286
|
+
iv: this.incoming.nextIv(),
|
|
287
|
+
additionalData: frame.subarray(0, 4),
|
|
288
|
+
tagLength: GCM_TAG_BYTES * 8
|
|
289
|
+
},
|
|
290
|
+
this.incoming.key,
|
|
291
|
+
frame.subarray(4)
|
|
292
|
+
));
|
|
293
|
+
} catch {
|
|
294
|
+
throw new Error("SSH: a packet failed its integrity check, so the session was dropped.");
|
|
295
|
+
}
|
|
296
|
+
const paddingLength = plain[0];
|
|
297
|
+
if (paddingLength + 1 > plain.length) throw new Error("SSH: a packet claimed more padding than it had.");
|
|
298
|
+
this.receiveSequence = this.receiveSequence + 1 >>> 0;
|
|
299
|
+
return plain.slice(1, plain.length - paddingLength);
|
|
300
|
+
}
|
|
301
|
+
#dispatchPacket(payload) {
|
|
302
|
+
if (payload.length === 0) return;
|
|
303
|
+
const type = payload[0];
|
|
304
|
+
switch (type) {
|
|
305
|
+
case MSG.DISCONNECT: {
|
|
306
|
+
const reader = new SshReader(payload, 1);
|
|
307
|
+
const code = reader.u32();
|
|
308
|
+
const description = reader.string();
|
|
309
|
+
const reason = description || DISCONNECT_REASON[code] || `reason ${code}`;
|
|
310
|
+
this.dispatchEvent(new CustomEvent("hostdisconnect", { detail: { code, reason } }));
|
|
311
|
+
this.#finish({ clean: code === 11, code: 1e3, message: reason });
|
|
312
|
+
try {
|
|
313
|
+
this.socket?.close(1e3, "Host disconnected");
|
|
314
|
+
} catch {
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
case MSG.IGNORE:
|
|
319
|
+
case MSG.UNIMPLEMENTED:
|
|
320
|
+
case MSG.EXT_INFO:
|
|
321
|
+
return;
|
|
322
|
+
case MSG.DEBUG: {
|
|
323
|
+
const reader = new SshReader(payload, 1);
|
|
324
|
+
const alwaysDisplay = reader.boolean();
|
|
325
|
+
const message = reader.string();
|
|
326
|
+
this.log("debug", { message, alwaysDisplay });
|
|
327
|
+
if (alwaysDisplay) {
|
|
328
|
+
this.dispatchEvent(new CustomEvent("banner", { detail: { text: `${message}\r
|
|
329
|
+
` } }));
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
case MSG.KEXINIT:
|
|
334
|
+
void this.#onKexInit(payload);
|
|
335
|
+
return;
|
|
336
|
+
case MSG.NEWKEYS:
|
|
337
|
+
this.#onNewKeys();
|
|
338
|
+
return;
|
|
339
|
+
case MSG.GLOBAL_REQUEST: {
|
|
340
|
+
const reader = new SshReader(payload, 1);
|
|
341
|
+
reader.string();
|
|
342
|
+
if (reader.boolean()) this.#sendPacket(new Uint8Array([MSG.REQUEST_FAILURE]));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
// The answer to the keepalive global request. Either one means the host
|
|
346
|
+
// is still there, which is the whole point of having asked.
|
|
347
|
+
case MSG.REQUEST_SUCCESS:
|
|
348
|
+
case MSG.REQUEST_FAILURE:
|
|
349
|
+
return;
|
|
350
|
+
default:
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
if (this.kex && type >= 30 && type <= 49) {
|
|
354
|
+
this.#deliverKexPacket(payload);
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
this.dispatchEvent(new CustomEvent("packet", { detail: { type, payload } }));
|
|
358
|
+
}
|
|
359
|
+
// --- sending -------------------------------------------------------------
|
|
360
|
+
/** Application packets wait while a key exchange is in flight (RFC 4253 7.1). */
|
|
361
|
+
send(payload) {
|
|
362
|
+
if (this.closed) return false;
|
|
363
|
+
if (this.kex?.running) {
|
|
364
|
+
this.kexQueue.push(payload);
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
this.#sendPacket(payload);
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
#sendPacket(payload) {
|
|
371
|
+
if (this.closed || this.socket?.readyState !== WebSocket.OPEN) return;
|
|
372
|
+
if (!this.outgoing) {
|
|
373
|
+
this.#write(framePlain(payload));
|
|
374
|
+
this.sendSequence = this.sendSequence + 1 >>> 0;
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
void this.#encrypt(payload);
|
|
378
|
+
}
|
|
379
|
+
async #encrypt(payload) {
|
|
380
|
+
const previous = this.encryptChain || Promise.resolve();
|
|
381
|
+
this.encryptChain = previous.then(async () => {
|
|
382
|
+
if (this.closed || !this.outgoing) return;
|
|
383
|
+
const paddingLength = gcmPaddingLength(payload.length);
|
|
384
|
+
const plain = new Uint8Array(1 + payload.length + paddingLength);
|
|
385
|
+
plain[0] = paddingLength;
|
|
386
|
+
plain.set(payload, 1);
|
|
387
|
+
crypto.getRandomValues(plain.subarray(1 + payload.length));
|
|
388
|
+
const header = new SshWriter(4).u32(plain.length).take();
|
|
389
|
+
const sealed = new Uint8Array(await subtle.encrypt(
|
|
390
|
+
{ name: "AES-GCM", iv: this.outgoing.nextIv(), additionalData: header, tagLength: GCM_TAG_BYTES * 8 },
|
|
391
|
+
this.outgoing.key,
|
|
392
|
+
plain
|
|
393
|
+
));
|
|
394
|
+
this.#write(concatBytes(header, sealed));
|
|
395
|
+
this.sendSequence = this.sendSequence + 1 >>> 0;
|
|
396
|
+
}).catch((error) => this.fail(error));
|
|
397
|
+
return this.encryptChain;
|
|
398
|
+
}
|
|
399
|
+
// --- key exchange --------------------------------------------------------
|
|
400
|
+
async #startKex() {
|
|
401
|
+
if (this.kex?.running) return;
|
|
402
|
+
this.kex = { running: true, clientKexInit: null, pending: null, inbox: [] };
|
|
403
|
+
try {
|
|
404
|
+
const offer = await negotiableAlgorithms();
|
|
405
|
+
const clientKexInit = buildKexInit(offer);
|
|
406
|
+
this.kex.clientKexInit = clientKexInit;
|
|
407
|
+
this.#sendPacket(clientKexInit);
|
|
408
|
+
const serverKexInit = this.pendingServerKexInit || await this.#nextKexPacket(MSG.KEXINIT);
|
|
409
|
+
this.pendingServerKexInit = null;
|
|
410
|
+
await this.#runKex(offer, clientKexInit, serverKexInit);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
this.fail(error);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/** The host may ask for new keys at any point in a session. */
|
|
416
|
+
async #onKexInit(payload) {
|
|
417
|
+
if (this.kex?.running) {
|
|
418
|
+
this.#deliverKexPacket(payload);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
this.log("rekey", { reason: "the host asked for new keys" });
|
|
422
|
+
this.pendingServerKexInit = payload;
|
|
423
|
+
await this.#startKex();
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Key exchange packets can arrive before the step that wants them: the host
|
|
427
|
+
* sends its reply and NEWKEYS back to back, while this side is still
|
|
428
|
+
* deriving a secret or waiting for the user to accept a host key. They are
|
|
429
|
+
* queued rather than dropped.
|
|
430
|
+
*/
|
|
431
|
+
#deliverKexPacket(payload) {
|
|
432
|
+
if (!this.kex) return;
|
|
433
|
+
if (this.kex.pending) {
|
|
434
|
+
const pending = this.kex.pending;
|
|
435
|
+
this.kex.pending = null;
|
|
436
|
+
pending(payload);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
this.kex.inbox.push(payload);
|
|
440
|
+
}
|
|
441
|
+
#nextKexPacket(expectedType) {
|
|
442
|
+
return new Promise((resolve, reject) => {
|
|
443
|
+
const check = (payload) => {
|
|
444
|
+
if (expectedType !== void 0 && payload[0] !== expectedType) {
|
|
445
|
+
reject(new Error(`SSH: expected message ${expectedType} during key exchange but got ${payload[0]}.`));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
resolve(payload);
|
|
449
|
+
};
|
|
450
|
+
const queued = this.kex.inbox.shift();
|
|
451
|
+
if (queued) {
|
|
452
|
+
check(queued);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
this.kex.pending = (payload) => {
|
|
456
|
+
this.kex.pending = null;
|
|
457
|
+
check(payload);
|
|
458
|
+
};
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
async #runKex(offer, clientKexInit, serverKexInit) {
|
|
462
|
+
const server = parseKexInit(serverKexInit);
|
|
463
|
+
const kexAlgorithm = negotiate(offer.kex, server.kex);
|
|
464
|
+
const hostKeyAlgorithm = negotiate(offer.hostKey, server.hostKey);
|
|
465
|
+
const cipherToServer = negotiate(CIPHER_NAMES, server.cipherClientToServer);
|
|
466
|
+
const cipherToClient = negotiate(CIPHER_NAMES, server.cipherServerToClient);
|
|
467
|
+
if (!kexAlgorithm) throw new Error(`SSH: no shared key exchange method. The host offers ${server.kex.join(", ")}.`);
|
|
468
|
+
if (!hostKeyAlgorithm) throw new Error(`SSH: no host key type this browser can verify. The host offers ${server.hostKey.join(", ")}.`);
|
|
469
|
+
if (!cipherToServer || !cipherToClient) {
|
|
470
|
+
throw new Error(`SSH: the host does not offer AES-GCM, which is the only cipher this client can use. It offers ${server.cipherServerToClient.join(", ")}.`);
|
|
471
|
+
}
|
|
472
|
+
this.log("negotiated", {
|
|
473
|
+
kex: kexAlgorithm,
|
|
474
|
+
hostKey: hostKeyAlgorithm,
|
|
475
|
+
cipher: cipherToClient,
|
|
476
|
+
rekey: Boolean(this.sessionId)
|
|
477
|
+
});
|
|
478
|
+
const client = await createKexClient(kexAlgorithm);
|
|
479
|
+
this.#sendPacket(new SshWriter(128).u8(MSG.KEX_ECDH_INIT).string(client.publicKey).take());
|
|
480
|
+
const reply = new SshReader(await this.#nextKexPacket(MSG.KEX_ECDH_REPLY), 1);
|
|
481
|
+
const hostKeyBlob = reply.stringBytes();
|
|
482
|
+
const serverPublicKey = reply.stringBytes();
|
|
483
|
+
const signatureBlob = reply.stringBytes();
|
|
484
|
+
const sharedSecret = await client.sharedSecret(serverPublicKey);
|
|
485
|
+
const hash = await exchangeHash({
|
|
486
|
+
hash: client.hash,
|
|
487
|
+
clientVersion: CLIENT_VERSION,
|
|
488
|
+
serverVersion: this.serverVersion,
|
|
489
|
+
clientKexInit,
|
|
490
|
+
serverKexInit,
|
|
491
|
+
hostKeyBlob,
|
|
492
|
+
clientPublicKey: client.publicKey,
|
|
493
|
+
serverPublicKey,
|
|
494
|
+
sharedSecret
|
|
495
|
+
});
|
|
496
|
+
const signatureValid = await verifyHostKeySignature({
|
|
497
|
+
algorithm: hostKeyAlgorithm,
|
|
498
|
+
keyBlob: hostKeyBlob,
|
|
499
|
+
signatureBlob,
|
|
500
|
+
exchangeHash: hash
|
|
501
|
+
});
|
|
502
|
+
if (!signatureValid) {
|
|
503
|
+
throw new Error("SSH: the host key signature is invalid, so this is not the machine it claims to be.");
|
|
504
|
+
}
|
|
505
|
+
const fingerprint = await hostKeyFingerprint(hostKeyBlob);
|
|
506
|
+
if (!this.sessionId) {
|
|
507
|
+
const accepted = await this.verifyHost({
|
|
508
|
+
fingerprint,
|
|
509
|
+
keyType: hostKeyType(hostKeyBlob),
|
|
510
|
+
algorithm: hostKeyAlgorithm
|
|
511
|
+
});
|
|
512
|
+
if (!accepted) throw new Error("SSH: the host key was not accepted, so the connection was stopped.");
|
|
513
|
+
this.sessionId = hash;
|
|
514
|
+
}
|
|
515
|
+
this.#sendPacket(new Uint8Array([MSG.NEWKEYS]));
|
|
516
|
+
await this.#nextKexPacket(MSG.NEWKEYS);
|
|
517
|
+
const parameters = {
|
|
518
|
+
hash: client.hash,
|
|
519
|
+
sharedSecret,
|
|
520
|
+
exchangeHash: hash,
|
|
521
|
+
sessionId: this.sessionId
|
|
522
|
+
};
|
|
523
|
+
const [ivToServer, ivToClient, keyToServer, keyToClient] = await Promise.all([
|
|
524
|
+
deriveKey({ ...parameters, letter: "A", length: GCM_IV_BYTES }),
|
|
525
|
+
deriveKey({ ...parameters, letter: "B", length: GCM_IV_BYTES }),
|
|
526
|
+
deriveKey({ ...parameters, letter: "C", length: CIPHERS[cipherToServer].keyLength }),
|
|
527
|
+
deriveKey({ ...parameters, letter: "D", length: CIPHERS[cipherToClient].keyLength })
|
|
528
|
+
]);
|
|
529
|
+
this.outgoing = await GcmCipher.create(keyToServer, ivToServer);
|
|
530
|
+
this.incoming = await GcmCipher.create(keyToClient, ivToClient);
|
|
531
|
+
this.kex.running = false;
|
|
532
|
+
this.releaseNewKeys?.();
|
|
533
|
+
this.releaseNewKeys = null;
|
|
534
|
+
const queued = this.kexQueue;
|
|
535
|
+
this.kexQueue = [];
|
|
536
|
+
for (const payload of queued) this.#sendPacket(payload);
|
|
537
|
+
const firstTime = !this.established;
|
|
538
|
+
this.established = true;
|
|
539
|
+
this.dispatchEvent(new CustomEvent("keys", {
|
|
540
|
+
detail: { fingerprint, kexAlgorithm, hostKeyAlgorithm, cipher: cipherToClient, firstTime }
|
|
541
|
+
}));
|
|
542
|
+
}
|
|
543
|
+
#onNewKeys() {
|
|
544
|
+
if (!this.kex) return;
|
|
545
|
+
this.newKeysBarrier = new Promise((resolve) => {
|
|
546
|
+
this.releaseNewKeys = resolve;
|
|
547
|
+
});
|
|
548
|
+
this.#deliverKexPacket(new Uint8Array([MSG.NEWKEYS]));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function gcmPaddingLength(payloadLength) {
|
|
552
|
+
const unpadded = 1 + payloadLength;
|
|
553
|
+
let padding = GCM_BLOCK - unpadded % GCM_BLOCK;
|
|
554
|
+
while (padding < MIN_PADDING) padding += GCM_BLOCK;
|
|
555
|
+
return padding;
|
|
556
|
+
}
|
|
557
|
+
function framePlain(payload) {
|
|
558
|
+
const unpadded = 5 + payload.length;
|
|
559
|
+
let padding = PLAIN_BLOCK - unpadded % PLAIN_BLOCK;
|
|
560
|
+
if (padding < MIN_PADDING) padding += PLAIN_BLOCK;
|
|
561
|
+
const writer = new SshWriter(unpadded + padding);
|
|
562
|
+
writer.u32(1 + payload.length + padding);
|
|
563
|
+
writer.u8(padding);
|
|
564
|
+
writer.raw(payload);
|
|
565
|
+
const frame = writer.take();
|
|
566
|
+
const filler = new Uint8Array(padding);
|
|
567
|
+
crypto.getRandomValues(filler);
|
|
568
|
+
return concatBytes(frame, filler);
|
|
569
|
+
}
|
|
570
|
+
function buildKexInit(offer) {
|
|
571
|
+
const cookie = new Uint8Array(16);
|
|
572
|
+
crypto.getRandomValues(cookie);
|
|
573
|
+
const writer = new SshWriter(512);
|
|
574
|
+
writer.u8(MSG.KEXINIT).raw(cookie);
|
|
575
|
+
writer.nameList(offer.kex);
|
|
576
|
+
writer.nameList(offer.hostKey);
|
|
577
|
+
writer.nameList(CIPHER_NAMES);
|
|
578
|
+
writer.nameList(CIPHER_NAMES);
|
|
579
|
+
writer.nameList(MAC_NAMES);
|
|
580
|
+
writer.nameList(MAC_NAMES);
|
|
581
|
+
writer.nameList(COMPRESSION_NAMES);
|
|
582
|
+
writer.nameList(COMPRESSION_NAMES);
|
|
583
|
+
writer.nameList([]);
|
|
584
|
+
writer.nameList([]);
|
|
585
|
+
writer.boolean(false);
|
|
586
|
+
writer.u32(0);
|
|
587
|
+
return writer.take();
|
|
588
|
+
}
|
|
589
|
+
function parseKexInit(payload) {
|
|
590
|
+
const reader = new SshReader(payload, 17);
|
|
591
|
+
return {
|
|
592
|
+
kex: reader.nameList(),
|
|
593
|
+
hostKey: reader.nameList(),
|
|
594
|
+
cipherClientToServer: reader.nameList(),
|
|
595
|
+
cipherServerToClient: reader.nameList(),
|
|
596
|
+
macClientToServer: reader.nameList(),
|
|
597
|
+
macServerToClient: reader.nameList(),
|
|
598
|
+
compressionClientToServer: reader.nameList(),
|
|
599
|
+
compressionServerToClient: reader.nameList()
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
export {
|
|
603
|
+
CLIENT_VERSION,
|
|
604
|
+
SshTransport,
|
|
605
|
+
buildKexInit,
|
|
606
|
+
framePlain,
|
|
607
|
+
gcmPaddingLength,
|
|
608
|
+
negotiate,
|
|
609
|
+
parseKexInit
|
|
610
|
+
};
|
|
611
|
+
//# sourceMappingURL=transport.js.map
|