tabby-rdp 0.5.0 → 0.5.2
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/CHANGELOG.md +516 -0
- package/README.md +216 -58
- package/TROUBLESHOOTING.md +77 -0
- package/dist/accountForm.js +0 -1
- package/dist/accountRevisions.js +46 -0
- package/dist/accounts.js +2 -2
- package/dist/audio.js +0 -1
- package/dist/authorities.js +331 -0
- package/dist/byteQueue.js +102 -0
- package/dist/clipboard.js +106 -0
- package/dist/connectionStatus.js +0 -1
- package/dist/desk.js +16 -47
- package/dist/deskScript.js +196 -6
- package/dist/desktop.service.js +1499 -269
- package/dist/desktopForm.js +53 -18
- package/dist/desktops.js +232 -24
- package/dist/diskStorage.js +212 -0
- package/dist/drives.js +271 -30
- package/dist/fileTransfer.js +960 -132
- package/dist/gateway.js +216 -57
- package/dist/header.js +76 -5
- package/dist/help.js +16 -47
- package/dist/hostCompat.js +86 -0
- package/dist/hyperv.js +65 -15
- package/dist/index.js +7 -4
- package/dist/keyboard.js +0 -1
- package/dist/microphone.js +57 -13
- package/dist/ntlm.js +0 -1
- package/dist/osd.js +56 -15
- package/dist/packageChangeDialog.js +186 -0
- package/dist/probe.js +15 -3
- package/dist/rdcleanpath.js +319 -44
- package/dist/rdpFile.js +22 -7
- package/dist/rdpProfile.js +39 -21
- package/dist/releaseSettings.js +72 -0
- package/dist/releases.js +408 -0
- package/dist/remoteSetup.js +103 -23
- package/dist/sessionLog.js +53 -0
- package/dist/settingsPage.js +148 -122
- package/dist/signin.js +478 -48
- package/dist/ssh.js +99 -18
- package/dist/sshChannelStream.js +40 -6
- package/dist/targets.js +194 -40
- package/dist/ui.js +88 -20
- package/dist/unshowable.js +41 -0
- package/dist/updates.js +79 -56
- package/dist/vms.js +17 -3
- package/dist/wake.js +15 -8
- package/docs/desk.md +157 -0
- package/package.json +14 -4
- package/remote/trd-pty.py +74 -13
- package/vendor/SHA256SUMS +26 -0
- package/vendor/ironrdp_web_bg.wasm +0 -0
- package/dist/accountForm.js.map +0 -1
- package/dist/accounts.js.map +0 -1
- package/dist/audio.js.map +0 -1
- package/dist/connectionStatus.js.map +0 -1
- package/dist/desk.js.map +0 -1
- package/dist/deskScript.js.map +0 -1
- package/dist/desktop.service.js.map +0 -1
- package/dist/desktopForm.js.map +0 -1
- package/dist/desktops.js.map +0 -1
- package/dist/drives.js.map +0 -1
- package/dist/fileTransfer.js.map +0 -1
- package/dist/gateway.js.map +0 -1
- package/dist/header.js.map +0 -1
- package/dist/help.js.map +0 -1
- package/dist/hyperv.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/keyboard.js.map +0 -1
- package/dist/microphone.js.map +0 -1
- package/dist/ntlm.js.map +0 -1
- package/dist/osd.js.map +0 -1
- package/dist/probe.js.map +0 -1
- package/dist/rdcleanpath.js.map +0 -1
- package/dist/rdpFile.js.map +0 -1
- package/dist/rdpProfile.js.map +0 -1
- package/dist/remoteSetup.js.map +0 -1
- package/dist/settingsPage.js.map +0 -1
- package/dist/signin.js.map +0 -1
- package/dist/ssh.js.map +0 -1
- package/dist/sshChannelStream.js.map +0 -1
- package/dist/targets.js.map +0 -1
- package/dist/ui.js.map +0 -1
- package/dist/updates.js.map +0 -1
- package/dist/vms.js.map +0 -1
- package/dist/wake.js.map +0 -1
package/dist/rdcleanpath.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FLOW_WINDOW = void 0;
|
|
3
4
|
exports.normalizeFingerprint = normalizeFingerprint;
|
|
4
5
|
exports.preconnectionPdu = preconnectionPdu;
|
|
5
6
|
exports.offersNla = offersNla;
|
|
7
|
+
exports.earlyUserAuthorization = earlyUserAuthorization;
|
|
8
|
+
exports.withoutUserCookie = withoutUserCookie;
|
|
6
9
|
exports.serverFraming = serverFraming;
|
|
10
|
+
exports.signsNothing = signsNothing;
|
|
11
|
+
exports.withoutToken = withoutToken;
|
|
7
12
|
exports.startRDCleanPathProxy = startRDCleanPathProxy;
|
|
8
13
|
// RDCleanPath proxy for the IronRDP web client, running inside Tabby's renderer.
|
|
9
14
|
// The WASM client talks WebSocket to this proxy; the proxy opens the upstream stream
|
|
@@ -11,6 +16,9 @@ exports.startRDCleanPathProxy = startRDCleanPathProxy;
|
|
|
11
16
|
// terminates TLS, checks the server's certificate, hands the cert chain back to the client (for CredSSP), then relays.
|
|
12
17
|
const tls = require("tls");
|
|
13
18
|
const crypto_1 = require("crypto");
|
|
19
|
+
const net_1 = require("net");
|
|
20
|
+
const authorities_1 = require("./authorities");
|
|
21
|
+
const byteQueue_1 = require("./byteQueue");
|
|
14
22
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
15
23
|
const { WebSocketServer } = require('ws');
|
|
16
24
|
const VERSION_1 = 3390;
|
|
@@ -23,6 +31,16 @@ function normalizeFingerprint(value) {
|
|
|
23
31
|
function fingerprintOf(der) {
|
|
24
32
|
return normalizeFingerprint((0, crypto_1.createHash)('sha256').update(der).digest('hex'));
|
|
25
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* What answers to the remote's requests may have taken of the window's memory, read for it or handed to the connection,
|
|
36
|
+
* beyond what the proxy has read from the connection (see ServerFlow): four of the largest parts of files a request
|
|
37
|
+
* gets. A link stays busy with far less, as the proxy lets no more than HIGH_WATER wait for the server.
|
|
38
|
+
*/
|
|
39
|
+
exports.FLOW_WINDOW = 64 * 1024 * 1024;
|
|
40
|
+
/** The answer to a server without Network Level Authentication where nobody decides otherwise: refused. */
|
|
41
|
+
function refuseWithoutNla() {
|
|
42
|
+
throw new Error('the server doesn\'t use Network Level Authentication');
|
|
43
|
+
}
|
|
26
44
|
// ---- minimal DER -------------------------------------------------------------------------------
|
|
27
45
|
function derLength(n) {
|
|
28
46
|
if (n < 0x80) {
|
|
@@ -128,7 +146,7 @@ function encodeError() {
|
|
|
128
146
|
// Reads exactly one TPKT-framed PDU (the X.224 Connection Confirm) without consuming TLS bytes.
|
|
129
147
|
function readTpkt(stream) {
|
|
130
148
|
return new Promise((resolve, reject) => {
|
|
131
|
-
|
|
149
|
+
const acc = new byteQueue_1.ByteQueue();
|
|
132
150
|
const cleanup = () => {
|
|
133
151
|
stream.off('data', onData);
|
|
134
152
|
stream.off('error', onError);
|
|
@@ -138,20 +156,30 @@ function readTpkt(stream) {
|
|
|
138
156
|
const onError = (e) => { cleanup(); reject(e); };
|
|
139
157
|
const onClose = () => { cleanup(); reject(new Error('upstream closed during X.224')); };
|
|
140
158
|
const onData = (chunk) => {
|
|
141
|
-
acc
|
|
159
|
+
acc.push(chunk);
|
|
142
160
|
if (acc.length < 4) {
|
|
143
161
|
return;
|
|
144
162
|
}
|
|
145
|
-
const
|
|
163
|
+
const header = acc.peek(4);
|
|
164
|
+
const len = header.readUInt16BE(2);
|
|
165
|
+
// TPKT is version 3, and an X.224 TPDU takes three bytes at least: anything else is not an RDP server
|
|
166
|
+
// answering (a web server on that port, say), and isn't waited on for the length it seems to give.
|
|
167
|
+
if (header[0] !== 3 || len < 7) {
|
|
168
|
+
cleanup();
|
|
169
|
+
reject(new Error('the server\'s answer isn\'t RDP (is that the desktop\'s port?)'));
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
146
172
|
if (acc.length < len) {
|
|
147
173
|
return;
|
|
148
174
|
}
|
|
149
175
|
stream.pause();
|
|
150
176
|
cleanup();
|
|
151
|
-
|
|
152
|
-
|
|
177
|
+
// A copy: it is kept for the connection, and is a few bytes of the queue's storage.
|
|
178
|
+
const pdu = Buffer.from(acc.take(len));
|
|
179
|
+
if (acc.length) {
|
|
180
|
+
stream.unshift(acc.take(acc.length));
|
|
153
181
|
}
|
|
154
|
-
resolve(
|
|
182
|
+
resolve(pdu);
|
|
155
183
|
};
|
|
156
184
|
stream.on('data', onData);
|
|
157
185
|
stream.on('error', onError);
|
|
@@ -171,11 +199,17 @@ function certChainOf(socket) {
|
|
|
171
199
|
}
|
|
172
200
|
return chain;
|
|
173
201
|
}
|
|
202
|
+
/** selectedProtocol's flags in the server's negotiation response (RDP_NEG_RSP). */
|
|
203
|
+
const PROTOCOL_HYBRID = 0x02;
|
|
204
|
+
const PROTOCOL_HYBRID_EX = 0x08;
|
|
174
205
|
/**
|
|
175
|
-
* The
|
|
176
|
-
*
|
|
177
|
-
*
|
|
206
|
+
* The security protocol the server chose (RDP_NEG_RSP's selectedProtocol), or 0 when its answer to the connection
|
|
207
|
+
* request has no negotiation response. Tested by its flags, as IronRDP does, so that the proxy and the client never
|
|
208
|
+
* read the same answer differently.
|
|
178
209
|
*/
|
|
210
|
+
function selectedProtocol(x224) {
|
|
211
|
+
return x224.length >= 19 && x224[11] === 0x02 ? x224.readUInt32LE(15) : 0;
|
|
212
|
+
}
|
|
179
213
|
/**
|
|
180
214
|
* Whether the server's answer to the connection request chose Network Level Authentication (CredSSP: HYBRID or
|
|
181
215
|
* HYBRID_EX). With it, the sign-in is a proof both ways and the password only goes to a server that knows it already.
|
|
@@ -183,8 +217,49 @@ function certChainOf(socket) {
|
|
|
183
217
|
* way, readable to whatever answered.
|
|
184
218
|
*/
|
|
185
219
|
function offersNla(x224) {
|
|
186
|
-
return
|
|
220
|
+
return (selectedProtocol(x224) & (PROTOCOL_HYBRID | PROTOCOL_HYBRID_EX)) !== 0;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Whether an Early User Authorization Result follows CredSSP: the server chose HYBRID_EX (see serverFraming). IronRDP
|
|
224
|
+
* expects one whenever that flag is set, alone or not.
|
|
225
|
+
*/
|
|
226
|
+
function earlyUserAuthorization(x224) {
|
|
227
|
+
return (selectedProtocol(x224) & PROTOCOL_HYBRID_EX) !== 0;
|
|
187
228
|
}
|
|
229
|
+
/** The routing cookie that names the user (see withoutUserCookie). */
|
|
230
|
+
const COOKIE = Buffer.from('Cookie: mstshash=');
|
|
231
|
+
/**
|
|
232
|
+
* The client's X.224 connection request without its routing cookie, `Cookie: mstshash=<user name>`, which IronRDP fills
|
|
233
|
+
* in with the user name. It goes before TLS, in the clear, and before the certificate check and the stop at a server
|
|
234
|
+
* without Network Level Authentication: the account's name would reach whoever watches the way there, and servers the
|
|
235
|
+
* proxy then refuses. The cookie is optional, and what it is for, routing by user name in a load balancer in front of
|
|
236
|
+
* several servers, the plugin doesn't take part in (it sends no load-balancing info either). A request that isn't a
|
|
237
|
+
* connection request with such a cookie comes back as it is; so does a broker's routing token (`Cookie: msts=`), which
|
|
238
|
+
* names no user. The proxy sends none that still has the cookie (see handshake).
|
|
239
|
+
*/
|
|
240
|
+
function withoutUserCookie(request) {
|
|
241
|
+
// TPKT (4: version 3, 0, length), then X.224: length indicator (the rest of the header), the CR code, DST-REF,
|
|
242
|
+
// SRC-REF and class (6), then the cookie, the negotiation request and the rest.
|
|
243
|
+
const li = request[4];
|
|
244
|
+
if (request.length < 11 + COOKIE.length || request[0] !== 3 || request.readUInt16BE(2) !== request.length ||
|
|
245
|
+
(request[5] & 0xf0) !== 0xe0 || 5 + li !== request.length || !request.subarray(11, 11 + COOKIE.length).equals(COOKIE)) {
|
|
246
|
+
return request;
|
|
247
|
+
}
|
|
248
|
+
const end = request.indexOf('\r\n', 11 + COOKIE.length);
|
|
249
|
+
if (end < 0) {
|
|
250
|
+
return request;
|
|
251
|
+
}
|
|
252
|
+
const cut = end + 2 - 11;
|
|
253
|
+
const out = Buffer.concat([request.subarray(0, 11), request.subarray(end + 2)]);
|
|
254
|
+
out.writeUInt16BE(request.length - cut, 2);
|
|
255
|
+
out[4] = li - cut;
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* The server's answer to the client's security negotiation, when it rules out TLS (which IronRDP and RDCleanPath
|
|
260
|
+
* need): a server with RDP's standard security only (xrdp with security_layer=rdp, for example) answers without a
|
|
261
|
+
* negotiation response, or with PROTOCOL_RDP, or refuses the negotiation. Null when TLS can go ahead.
|
|
262
|
+
*/
|
|
188
263
|
function noTls(x224) {
|
|
189
264
|
// TPKT (4), X.224 Connection Confirm (7), then RDP_NEG_RSP or RDP_NEG_FAILURE (8): type, flags, length, value.
|
|
190
265
|
const standardOnly = 'the RDP server only offers standard RDP security, without TLS (for xrdp: set security_layer=negotiate in /etc/xrdp/xrdp.ini and restart xrdp)';
|
|
@@ -268,9 +343,11 @@ function autologon() {
|
|
|
268
343
|
* function from server data to the PDUs it completes; it throws on data that is none of these.
|
|
269
344
|
*/
|
|
270
345
|
function serverFraming(earlyAuth) {
|
|
271
|
-
|
|
346
|
+
// A server's CredSSP messages are small: its NTLM challenge or Kerberos reply, and its proof of the public key, a
|
|
347
|
+
// few kilobytes at most. A larger claim is no server's: it would only have Tabby keep whatever comes, and wait.
|
|
348
|
+
const MAX_DER = 256 * 1024;
|
|
272
349
|
let rdp = false;
|
|
273
|
-
|
|
350
|
+
const pending = new byteQueue_1.ByteQueue();
|
|
274
351
|
const invalid = () => new Error('invalid RDP server frame');
|
|
275
352
|
/** The length of the PDU at the start of `b`, 0 while its header is incomplete. */
|
|
276
353
|
const lengthOf = (b) => {
|
|
@@ -329,24 +406,23 @@ function serverFraming(earlyAuth) {
|
|
|
329
406
|
throw invalid();
|
|
330
407
|
};
|
|
331
408
|
return data => {
|
|
332
|
-
pending
|
|
409
|
+
pending.push(data);
|
|
333
410
|
const out = [];
|
|
334
411
|
for (;;) {
|
|
335
|
-
|
|
412
|
+
// Six bytes hold any of these headers: DER's tag and length with up to four length octets.
|
|
413
|
+
const head = pending.peek(6);
|
|
414
|
+
const length = lengthOf(head);
|
|
336
415
|
if (!length || pending.length < length) {
|
|
337
416
|
break;
|
|
338
417
|
}
|
|
339
|
-
if (!rdp && earlyAuth &&
|
|
418
|
+
if (!rdp && earlyAuth && head[0] !== 0x30) {
|
|
340
419
|
earlyAuth = false;
|
|
341
420
|
}
|
|
342
|
-
else if (
|
|
421
|
+
else if (head[0] === 0x03) {
|
|
343
422
|
rdp = true;
|
|
344
423
|
}
|
|
345
|
-
out.push(pending.
|
|
346
|
-
pending = pending.subarray(length);
|
|
424
|
+
out.push(pending.take(length));
|
|
347
425
|
}
|
|
348
|
-
// A copy of the rest: it would otherwise keep the whole chunk it came in alive.
|
|
349
|
-
pending = Buffer.from(pending);
|
|
350
426
|
return out;
|
|
351
427
|
};
|
|
352
428
|
}
|
|
@@ -361,6 +437,65 @@ const KEY_ENCIPHERMENT_ONLY_TLS = {
|
|
|
361
437
|
maxVersion: 'TLSv1.2',
|
|
362
438
|
ciphers: 'AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256',
|
|
363
439
|
};
|
|
440
|
+
/**
|
|
441
|
+
* The TLS library turned the server's certificate down for its key usage, the one failure KEY_ENCIPHERMENT_ONLY_TLS is
|
|
442
|
+
* for. Known by the library's own reason (Node's code is made from it), never by an error's text: that can carry what
|
|
443
|
+
* a gateway or an SSH host put in it, which would take forward secrecy away from a server that has no need to lose it.
|
|
444
|
+
* And only when the TLS socket raised it itself: an error the stream under it failed with reaches the TLS socket as it
|
|
445
|
+
* is, code and all (Electron's BoringSSL raises its own refusal with that stream untouched).
|
|
446
|
+
*/
|
|
447
|
+
class KeyUsageRefused extends Error {
|
|
448
|
+
}
|
|
449
|
+
const isKeyUsageRefusal = (e) => e?.code === 'ERR_SSL_KEY_USAGE_BIT_INCORRECT' || e?.reason === 'KEY_USAGE_BIT_INCORRECT';
|
|
450
|
+
const KEY_USAGE = Buffer.from([0x55, 0x1d, 0x0f]); // the key usage extension's OID, 2.5.29.15
|
|
451
|
+
/**
|
|
452
|
+
* Whether a certificate (DER) shows that it leaves digital signatures out of its key usage, as Windows' self-signed RDP
|
|
453
|
+
* certificate does (key and data encipherment only): the certificates KEY_ENCIPHERMENT_ONLY_TLS is for. False for one
|
|
454
|
+
* that allows them, has no key usage at all (anything goes), or can't be read here: the fallback, which gives up
|
|
455
|
+
* forward secrecy, is only for a certificate shown to need it.
|
|
456
|
+
*/
|
|
457
|
+
function signsNothing(der) {
|
|
458
|
+
const at = (offset, end) => {
|
|
459
|
+
const t = readTlv(der, offset);
|
|
460
|
+
if (!t || t.end > end) {
|
|
461
|
+
throw new Error('not DER');
|
|
462
|
+
}
|
|
463
|
+
return t;
|
|
464
|
+
};
|
|
465
|
+
const children = (parent) => {
|
|
466
|
+
const list = [];
|
|
467
|
+
for (let p = parent.start; p < parent.end; p = list[list.length - 1].end) {
|
|
468
|
+
list.push(at(p, parent.end));
|
|
469
|
+
}
|
|
470
|
+
return list;
|
|
471
|
+
};
|
|
472
|
+
try {
|
|
473
|
+
// Certificate: SEQUENCE { tbsCertificate: SEQUENCE { ..., extensions: [3] { SEQUENCE OF Extension } }, ... }
|
|
474
|
+
const certificate = at(0, der.length);
|
|
475
|
+
for (const field of children(at(certificate.start, certificate.end))) {
|
|
476
|
+
if (field.tag !== 0xa3) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
for (const extension of children(at(field.start, field.end))) {
|
|
480
|
+
// Extension: SEQUENCE { extnID OID, critical BOOLEAN (optional), extnValue OCTET STRING }
|
|
481
|
+
const [id, ...rest] = children(extension);
|
|
482
|
+
if (id.tag === 0x06 && der.subarray(id.start, id.end).equals(KEY_USAGE)) {
|
|
483
|
+
const value = rest[rest.length - 1];
|
|
484
|
+
const bits = at(value.start, value.end);
|
|
485
|
+
if (bits.tag !== 0x03) {
|
|
486
|
+
throw new Error('not a key usage');
|
|
487
|
+
}
|
|
488
|
+
// A BIT STRING: the count of unused bits, then the bits; digitalSignature is the first.
|
|
489
|
+
return bits.end - bits.start < 2 || !(der[bits.start + 1] & 0x80);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
return false;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
364
499
|
class NoTlsError extends Error {
|
|
365
500
|
}
|
|
366
501
|
/** The client's first message (the RDCleanPath request: a destination, a token, an X.224 request) is small. */
|
|
@@ -380,10 +515,17 @@ const HIGH_WATER = 8 * 1024 * 1024;
|
|
|
380
515
|
* Sends the client's X.224 request upstream, reads the confirm, then does TLS there. With a pre-connection blob, that
|
|
381
516
|
* goes first; without an X.224 request (a Hyper-V VM's console), TLS follows the blob at once and X.224 is the
|
|
382
517
|
* client's to do later. `track` hears of each stream as soon as it exists, so the caller can end them if the client
|
|
383
|
-
* goes away meanwhile; `gone` says it has
|
|
518
|
+
* goes away meanwhile; `gone` says it has, and `signal` aborts then, for the factory that opens the first stream (which
|
|
519
|
+
* `track` only hears of once it is open).
|
|
384
520
|
*/
|
|
385
|
-
async function handshake(openUpstream, destination, pcb, x224Request, legacyTls, track, gone, withoutNla) {
|
|
386
|
-
|
|
521
|
+
async function handshake(openUpstream, destination, pcb, x224Request, legacyTls, track, gone, withoutNla, options, signal) {
|
|
522
|
+
// Without the user name IronRDP puts in it. A request that still names one after that (its form not the one
|
|
523
|
+
// withoutUserCookie knows) goes nowhere: the name isn't to reach the server before it is checked.
|
|
524
|
+
const request = x224Request && withoutUserCookie(x224Request);
|
|
525
|
+
if (request?.includes(COOKIE)) {
|
|
526
|
+
throw new Error('the connection request carries the user name in a form the proxy can\'t take it out of');
|
|
527
|
+
}
|
|
528
|
+
const raw = await openUpstream(destination, signal);
|
|
387
529
|
track(raw);
|
|
388
530
|
try {
|
|
389
531
|
if (gone()) {
|
|
@@ -393,8 +535,8 @@ async function handshake(openUpstream, destination, pcb, x224Request, legacyTls,
|
|
|
393
535
|
raw.write(preconnectionPdu(pcb));
|
|
394
536
|
}
|
|
395
537
|
let x224 = null;
|
|
396
|
-
if (
|
|
397
|
-
raw.write(
|
|
538
|
+
if (request) {
|
|
539
|
+
raw.write(request);
|
|
398
540
|
x224 = await readTpkt(raw);
|
|
399
541
|
const refused = noTls(x224);
|
|
400
542
|
if (refused) {
|
|
@@ -402,14 +544,27 @@ async function handshake(openUpstream, destination, pcb, x224Request, legacyTls,
|
|
|
402
544
|
}
|
|
403
545
|
// Before TLS: a server that isn't to get the password gets nothing more at all.
|
|
404
546
|
if (!offersNla(x224)) {
|
|
405
|
-
await withoutNla
|
|
547
|
+
await withoutNla();
|
|
406
548
|
}
|
|
407
549
|
}
|
|
408
|
-
|
|
550
|
+
// With a name, the certificate is checked against it (an address against the certificate's addresses; it goes
|
|
551
|
+
// without SNI, which only takes names), the authorities and its dates. Whatever the outcome, the handshake
|
|
552
|
+
// completes: the certificate check decides.
|
|
553
|
+
const name = options.serverName;
|
|
554
|
+
const upstream = tls.connect({
|
|
555
|
+
socket: raw, rejectUnauthorized: false,
|
|
556
|
+
...name ? { host: name, ca: (0, authorities_1.certificateAuthorities)(), ...(0, net_1.isIP)(name) ? {} : { servername: name } } : {},
|
|
557
|
+
...legacyTls ? KEY_ENCIPHERMENT_ONLY_TLS : {}, ...options.tls,
|
|
558
|
+
});
|
|
409
559
|
track(upstream);
|
|
560
|
+
// An error after the one the handshake below waits for (the stream under it failing as the socket is destroyed,
|
|
561
|
+
// which a TLS socket over a stream passes on as its own) would have no listener until the relay is up, and an
|
|
562
|
+
// 'error' without one is an uncaught exception in Tabby's window. The relay listens for its own.
|
|
563
|
+
upstream.on('error', () => { });
|
|
410
564
|
await new Promise((resolve, reject) => {
|
|
411
565
|
upstream.once('secureConnect', resolve);
|
|
412
|
-
|
|
566
|
+
// TLS's own refusal only: not an error of the stream under it (raw.errored), whatever its code.
|
|
567
|
+
upstream.once('error', e => reject(isKeyUsageRefusal(e) && !raw.errored ? new KeyUsageRefused(e.message) : e));
|
|
413
568
|
upstream.once('close', () => reject(new Error('upstream closed during TLS')));
|
|
414
569
|
upstream.once('end', () => reject(new Error('upstream closed during TLS')));
|
|
415
570
|
});
|
|
@@ -420,21 +575,72 @@ async function handshake(openUpstream, destination, pcb, x224Request, legacyTls,
|
|
|
420
575
|
throw e;
|
|
421
576
|
}
|
|
422
577
|
}
|
|
423
|
-
|
|
578
|
+
/** Whether `given` is the secret, compared in a time that doesn't tell how much of it was right. */
|
|
579
|
+
function sameSecret(given, secret) {
|
|
580
|
+
const bytes = Buffer.from(given);
|
|
581
|
+
return bytes.length === secret.length && (0, crypto_1.timingSafeEqual)(bytes, secret);
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* The Origin a client may say it comes from: none (not a browser's page), or a file's page, as Tabby's window is (its
|
|
585
|
+
* WebSockets carry `Origin: file://`). Not a web page (`http:`, `https:`), a sandboxed or otherwise opaque one (`null`),
|
|
586
|
+
* or a browser extension's.
|
|
587
|
+
*/
|
|
588
|
+
const ALLOWED_ORIGINS = [undefined, 'file://'];
|
|
589
|
+
/**
|
|
590
|
+
* `text` without the proxy's token. IronRDP's error for an address it couldn't connect to names the address, whose path
|
|
591
|
+
* is the token, and such an error goes to the desktop's status line and its log, which "Copy log" hands out.
|
|
592
|
+
*/
|
|
593
|
+
function withoutToken(text, proxy) {
|
|
594
|
+
return proxy?.token ? text.split(proxy.token).join('…') : text;
|
|
595
|
+
}
|
|
596
|
+
async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () => { }, options = { withoutNla: refuseWithoutNla }) {
|
|
597
|
+
// Called from JavaScript without the decision, the proxy still refuses such servers rather than let them through.
|
|
598
|
+
if (typeof options.withoutNla !== 'function') {
|
|
599
|
+
options = { ...options, withoutNla: refuseWithoutNla };
|
|
600
|
+
}
|
|
424
601
|
const token = (0, crypto_1.randomBytes)(24).toString('hex');
|
|
602
|
+
// The client shows it twice: as its address's path, before it is let in at all, and in its request.
|
|
603
|
+
const path = Buffer.from(`/${token}`);
|
|
604
|
+
const secret = Buffer.from(token);
|
|
425
605
|
let failure = null;
|
|
426
|
-
// Set once
|
|
606
|
+
// Set once a connection showed that the server needs KEY_ENCIPHERMENT_ONLY_TLS (its handshake fell back, and the
|
|
607
|
+
// certificate leaves signatures out and was accepted). The next connection starts with it and clears it, to set it
|
|
608
|
+
// again once it has shown the same: one that doesn't get that far, whatever stops it (a failure, the server not
|
|
609
|
+
// answering in time, the client leaving), or that finds a certificate that doesn't need it, leaves it cleared.
|
|
427
610
|
let legacyTls = false;
|
|
428
611
|
const stats = { bytesIn: 0, bytesOut: 0 };
|
|
429
|
-
|
|
612
|
+
// The room (see ServerFlow): what was taken for answers and not read from the connection since, the client whose
|
|
613
|
+
// connection that is (the latest to reach the relay: a reconnect can overlap the connection it replaces), and what
|
|
614
|
+
// waits for room. Once the proxy is closed there is room for good: nothing more goes anywhere.
|
|
615
|
+
let owed = 0;
|
|
616
|
+
let flowClient = null;
|
|
617
|
+
let roomWaiters = [];
|
|
618
|
+
let shut = false;
|
|
619
|
+
const hasRoom = () => shut || owed < exports.FLOW_WINDOW;
|
|
620
|
+
const roomMade = () => {
|
|
621
|
+
if (roomWaiters.length && hasRoom()) {
|
|
622
|
+
const go = roomWaiters;
|
|
623
|
+
roomWaiters = [];
|
|
624
|
+
go.forEach(f => f());
|
|
625
|
+
}
|
|
626
|
+
};
|
|
627
|
+
const wss = new WebSocketServer({
|
|
628
|
+
host: '127.0.0.1',
|
|
629
|
+
port: 0,
|
|
630
|
+
maxPayload: MAX_MESSAGE,
|
|
631
|
+
// Anything on this computer can reach the port, and so can a web page in a browser. A connection without the
|
|
632
|
+
// token is turned away before it takes one of the few places, which the desktop's own client would then find
|
|
633
|
+
// taken; and one from a page other than a file's, as Tabby's window is, is turned away whatever it has.
|
|
634
|
+
verifyClient: ({ req }) => ALLOWED_ORIGINS.includes(req.headers.origin) && sameSecret(req.url ?? '', path),
|
|
635
|
+
});
|
|
430
636
|
await new Promise((resolve, reject) => {
|
|
431
637
|
wss.once('listening', resolve);
|
|
432
638
|
wss.once('error', reject);
|
|
433
639
|
});
|
|
434
640
|
wss.on('error', (e) => log(`proxy: ${e.message}`));
|
|
435
641
|
wss.on('connection', (ws) => {
|
|
436
|
-
//
|
|
437
|
-
//
|
|
642
|
+
// Only a client that knows the token gets here (see verifyClient), and then only as far as its first message
|
|
643
|
+
// (which has to be small and arrive soon) until that shows the token too.
|
|
438
644
|
if (wss.clients.size > MAX_CLIENTS) {
|
|
439
645
|
ws.terminate();
|
|
440
646
|
return;
|
|
@@ -445,6 +651,8 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
|
|
|
445
651
|
let earlySize = 0;
|
|
446
652
|
let upstream = null;
|
|
447
653
|
let closed = false;
|
|
654
|
+
// Aborts when the client has gone, for an upstream still being opened, which isn't among the streams yet.
|
|
655
|
+
const leaving = new AbortController();
|
|
448
656
|
// Every stream opened for this client, from the moment it exists: ended with the client.
|
|
449
657
|
const streams = new Set();
|
|
450
658
|
const track = (stream) => {
|
|
@@ -456,16 +664,24 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
|
|
|
456
664
|
};
|
|
457
665
|
const end = () => {
|
|
458
666
|
closed = true;
|
|
667
|
+
leaving.abort();
|
|
459
668
|
clearTimeout(deadline);
|
|
460
669
|
for (const stream of streams) {
|
|
461
670
|
stream.destroy();
|
|
462
671
|
}
|
|
672
|
+
// What was handed to this connection went with it: the next starts with all the room.
|
|
673
|
+
if (flowClient === ws) {
|
|
674
|
+
flowClient = null;
|
|
675
|
+
owed = 0;
|
|
676
|
+
roomMade();
|
|
677
|
+
}
|
|
463
678
|
};
|
|
464
679
|
let deadline = setTimeout(() => stage === 'hello' && ws.terminate(), HELLO_TIMEOUT_MS);
|
|
465
680
|
const toServer = options.autologon ? autologon() : (data) => data;
|
|
466
681
|
const send = (data) => {
|
|
467
682
|
const out = toServer(data);
|
|
468
|
-
// The server (or the SSH channel to it) not keeping up: stop reading from the client until it has
|
|
683
|
+
// The server (or the SSH channel to it) not keeping up: stop reading from the client until it has (see
|
|
684
|
+
// ServerFlow).
|
|
469
685
|
if (out.length && !upstream.write(out) && upstream.writableLength > HIGH_WATER) {
|
|
470
686
|
ws.pause();
|
|
471
687
|
upstream.once('drain', () => ws.resume());
|
|
@@ -490,6 +706,11 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
|
|
|
490
706
|
ws.on('message', async (data) => {
|
|
491
707
|
if (stage === 'relay') {
|
|
492
708
|
stats.bytesOut += data.length;
|
|
709
|
+
// Read from the connection: as much room again (see ServerFlow).
|
|
710
|
+
if (flowClient === ws) {
|
|
711
|
+
owed = Math.max(0, owed - data.length);
|
|
712
|
+
roomMade();
|
|
713
|
+
}
|
|
493
714
|
send(data);
|
|
494
715
|
return;
|
|
495
716
|
}
|
|
@@ -519,22 +740,29 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
|
|
|
519
740
|
if (req.version !== VERSION_1 || !req.destination || !req.x224 && !req.pcb?.trim()) {
|
|
520
741
|
return fail('malformed request');
|
|
521
742
|
}
|
|
522
|
-
if (req.proxyAuth
|
|
743
|
+
if (!sameSecret(req.proxyAuth ?? '', secret)) {
|
|
523
744
|
return fail('bad token');
|
|
524
745
|
}
|
|
525
|
-
deadline = setTimeout(() => stage === 'handshake' && fail('the server didn\'t answer in time'), HANDSHAKE_TIMEOUT_MS);
|
|
746
|
+
deadline = setTimeout(() => stage === 'handshake' && fail('the server didn\'t answer in time'), options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS);
|
|
747
|
+
// Whether this connection uses KEY_ENCIPHERMENT_ONLY_TLS, and whether it came to it by falling back
|
|
748
|
+
// (rather than starting with it, as remembered): kept here until the connection shows it was needed.
|
|
749
|
+
// Taken from legacyTls, which stays cleared until then: a server that hangs, or a client that leaves
|
|
750
|
+
// meanwhile, has the next connection try modern TLS first, as one whose handshake fails does.
|
|
751
|
+
let legacy = legacyTls;
|
|
752
|
+
legacyTls = false;
|
|
753
|
+
let fellBack = false;
|
|
526
754
|
let up;
|
|
527
755
|
try {
|
|
528
|
-
up = await handshake(openUpstream, req.destination, req.pcb, req.x224,
|
|
756
|
+
up = await handshake(openUpstream, req.destination, req.pcb, req.x224, legacy, track, () => closed, options.withoutNla, options, leaving.signal);
|
|
529
757
|
}
|
|
530
758
|
catch (e) {
|
|
531
|
-
if (closed ||
|
|
759
|
+
if (closed || legacy || !(e instanceof KeyUsageRefused)) {
|
|
532
760
|
throw e;
|
|
533
761
|
}
|
|
534
762
|
// The X.224 exchange is spent on that connection: start over on a fresh one.
|
|
535
763
|
log('TLS: the server certificate only allows key encipherment; using TLS 1.2 with RSA key exchange');
|
|
536
|
-
|
|
537
|
-
up = await handshake(openUpstream, req.destination, req.pcb, req.x224,
|
|
764
|
+
legacy = fellBack = true;
|
|
765
|
+
up = await handshake(openUpstream, req.destination, req.pcb, req.x224, legacy, track, () => closed, options.withoutNla, options, leaving.signal);
|
|
538
766
|
}
|
|
539
767
|
// Reached: from here on the time is the user's (a certificate to look at) and the session's.
|
|
540
768
|
clearTimeout(deadline);
|
|
@@ -550,15 +778,45 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
|
|
|
550
778
|
if (!chain.length) {
|
|
551
779
|
return fail('the server sent no certificate');
|
|
552
780
|
}
|
|
553
|
-
|
|
781
|
+
// RSA key exchange, without forward secrecy, only for a certificate shown to leave no better choice. One
|
|
782
|
+
// that doesn't show it, after a handshake that failed as if it did, means something on the way made
|
|
783
|
+
// that failure up; after one that started with it as remembered, that the certificate changed. Either
|
|
784
|
+
// way the next connection starts without the fallback.
|
|
785
|
+
if (legacy && !signsNothing(chain[0])) {
|
|
786
|
+
legacyTls = false;
|
|
787
|
+
return fail(fellBack
|
|
788
|
+
? 'the server\'s certificate doesn\'t show a need for the fallback to TLS 1.2 with RSA key exchange (no forward secrecy) that this connection made: something on the way may be interfering with TLS'
|
|
789
|
+
: 'the server\'s certificate changed, and no longer needs TLS 1.2 with RSA key exchange: the next connection uses modern TLS');
|
|
790
|
+
}
|
|
791
|
+
// Valid only as checked against a name: without one, Node checks against "localhost".
|
|
792
|
+
const valid = !!options.serverName && upstream.authorized;
|
|
793
|
+
const reason = valid ? '' : options.serverName ? String(upstream.authorizationError ?? '') : '';
|
|
794
|
+
// Where the chain stands and whether it is for the name, which Node's code for why it isn't valid can
|
|
795
|
+
// hide (see certificateDetails), by the authorities the handshake was checked against.
|
|
796
|
+
const peer = upstream.getPeerCertificate(true);
|
|
797
|
+
const checked = options.serverName && !valid ? { name: options.serverName, authorities: options.tls?.ca ?? (0, authorities_1.certificateAuthorities)(), reason } : undefined;
|
|
798
|
+
await checkCertificate(fingerprintOf(chain[0]), valid, reason, (0, authorities_1.certificateDetails)(peer, checked));
|
|
554
799
|
if (closed) {
|
|
555
800
|
return;
|
|
556
801
|
}
|
|
802
|
+
// Remembered only now: the handshake worked, with a certificate that needs it and that was accepted.
|
|
803
|
+
legacyTls = legacy;
|
|
804
|
+
// A server without NLA, let through before TLS: what let it through is held to its certificate.
|
|
805
|
+
if (x224 && !offersNla(x224) && options.withoutNlaCertificate) {
|
|
806
|
+
await options.withoutNlaCertificate(fingerprintOf(chain[0]));
|
|
807
|
+
if (closed) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
557
811
|
ws.send(encodeResponse(x224, chain, req.destination));
|
|
558
812
|
stage = 'relay';
|
|
813
|
+
// The room is this connection's from now on, all of it (see ServerFlow).
|
|
814
|
+
flowClient = ws;
|
|
815
|
+
owed = 0;
|
|
816
|
+
roomMade();
|
|
559
817
|
// PROTOCOL_HYBRID_EX in the server's negotiation response: an Early User Authorization Result follows CredSSP.
|
|
560
818
|
// (A Hyper-V VM's console: CredSSP first, then the X.224 exchange, which never asks for HYBRID_EX.)
|
|
561
|
-
const frames = serverFraming(!!x224 && x224
|
|
819
|
+
const frames = serverFraming(!!x224 && earlyUserAuthorization(x224));
|
|
562
820
|
upstream.on('data', (d) => {
|
|
563
821
|
stats.bytesIn += d.length;
|
|
564
822
|
let pdus;
|
|
@@ -610,7 +868,7 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
|
|
|
610
868
|
});
|
|
611
869
|
const { port } = wss.address();
|
|
612
870
|
return {
|
|
613
|
-
url: `ws://127.0.0.1:${port}`,
|
|
871
|
+
url: `ws://127.0.0.1:${port}/${token}`,
|
|
614
872
|
token,
|
|
615
873
|
stats,
|
|
616
874
|
get failure() { return failure; },
|
|
@@ -620,7 +878,24 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
|
|
|
620
878
|
for (const client of wss.clients) {
|
|
621
879
|
client.terminate();
|
|
622
880
|
}
|
|
881
|
+
// Nothing more goes to the server: what waited for room goes on, and finds the end.
|
|
882
|
+
shut = true;
|
|
883
|
+
owed = 0;
|
|
884
|
+
roomMade();
|
|
885
|
+
},
|
|
886
|
+
room: hasRoom,
|
|
887
|
+
// Counted only as a number of bytes: anything else would leave the room unknown, and every answer waiting.
|
|
888
|
+
spend: (bytes) => {
|
|
889
|
+
if (bytes > 0) {
|
|
890
|
+
owed += bytes;
|
|
891
|
+
}
|
|
892
|
+
},
|
|
893
|
+
refund: (bytes) => {
|
|
894
|
+
if (bytes > 0) {
|
|
895
|
+
owed = Math.max(0, owed - bytes);
|
|
896
|
+
roomMade();
|
|
897
|
+
}
|
|
623
898
|
},
|
|
899
|
+
whenRoom: () => hasRoom() ? Promise.resolve() : new Promise(resolve => roomWaiters.push(resolve)),
|
|
624
900
|
};
|
|
625
901
|
}
|
|
626
|
-
//# sourceMappingURL=rdcleanpath.js.map
|
package/dist/rdpFile.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MICROPHONE_NOTE = void 0;
|
|
3
4
|
exports.parseRdpFile = parseRdpFile;
|
|
5
|
+
/**
|
|
6
|
+
* The note for a file that asks for the microphone: not applied while the setting is off. With it on, every desktop
|
|
7
|
+
* gets the microphone, and the import says so instead (see RemoteDesktopService.importRdp).
|
|
8
|
+
*/
|
|
9
|
+
exports.MICROPHONE_NOTE = 'the microphone (Settings › Remote Desktop › Microphone)';
|
|
4
10
|
/** What a setting in the file would mean here, when it isn't applied: by key, for the values that ask for something. */
|
|
5
11
|
const NOTES = {
|
|
6
12
|
'audiomode': v => v === '1' ? 'sound left on the remote (sound plays here; Settings › Remote Desktop › Sound)' : v === '2' ? 'sound off (Settings › Remote Desktop › Sound)' : null,
|
|
7
|
-
'audiocapturemode': v => v === '1' ?
|
|
8
|
-
'redirectclipboard': v => v === '0' ? 'the clipboard turned off (it is always on)' : null,
|
|
13
|
+
'audiocapturemode': v => v === '1' ? exports.MICROPHONE_NOTE : null,
|
|
9
14
|
'redirectdrives': v => v === '1' ? 'its drives (share folders in Settings › Remote Desktop › Shared folders)' : null,
|
|
10
15
|
'drivestoredirect': v => v ? 'its drives (share folders in Settings › Remote Desktop › Shared folders)' : null,
|
|
11
16
|
'redirectprinters': v => v === '1' ? 'printer redirection (not supported)' : null,
|
|
@@ -51,15 +56,22 @@ function decode(data) {
|
|
|
51
56
|
/** The connection a .rdp file describes, or null without a usable `full address`. */
|
|
52
57
|
function parseRdpFile(data) {
|
|
53
58
|
const settings = new Map();
|
|
59
|
+
// A limit is kept whichever way the file writes it (`0`, `00`, `+0`), and wherever it is: a file that says it
|
|
60
|
+
// twice, differently, gets the narrower of the two rather than whichever line comes last.
|
|
61
|
+
let clipboardOff = false;
|
|
54
62
|
for (const line of decode(data).split(/\r?\n/)) {
|
|
55
63
|
const m = /^\s*([^:]+?)\s*:\s*([sib])\s*:(.*)$/i.exec(line);
|
|
56
64
|
if (m) {
|
|
57
|
-
|
|
65
|
+
const key = m[1].toLowerCase();
|
|
66
|
+
const value = m[3].trim();
|
|
67
|
+
settings.set(key, value);
|
|
68
|
+
clipboardOff ||= key === 'redirectclipboard' && /^[+-]?\d+$/.test(value) && Number(value) === 0;
|
|
58
69
|
}
|
|
59
70
|
}
|
|
60
71
|
const full = settings.get('full address') ?? '';
|
|
61
|
-
// host, host:port, [v6]:port, or a bare IPv6 address (colons, no brackets).
|
|
62
|
-
|
|
72
|
+
// host, host:port, [v6]:port, or a bare IPv6 address (colons, no brackets). No '#': session and trust keys are
|
|
73
|
+
// split on it, so a host that carried one could be taken for another desktop's key (see keyParts).
|
|
74
|
+
const m = /^\[([^\]#]+)\](?::(\d+))?$|^([^:\s#]+)(?::(\d+))?$/.exec(full);
|
|
63
75
|
const host = m?.[1] ?? m?.[3] ?? (/^[0-9a-f:.]+$/i.test(full) && (full.match(/:/g)?.length ?? 0) > 1 ? full : null);
|
|
64
76
|
// A port in the address wins over `server port`, as in mstsc.
|
|
65
77
|
const port = Number(m?.[2] ?? m?.[4] ?? (settings.get('server port') || 3389));
|
|
@@ -67,7 +79,10 @@ function parseRdpFile(data) {
|
|
|
67
79
|
return null;
|
|
68
80
|
}
|
|
69
81
|
const scale = Number(settings.get('desktopscalefactor'));
|
|
70
|
-
|
|
82
|
+
// By own property only: a file whose key is `__proto__` or `constructor` names an inherited member of the NOTES
|
|
83
|
+
// object (Object.prototype, the Object constructor), not a note, and calling one would throw or inject its text.
|
|
84
|
+
const noteFor = (key) => Object.prototype.hasOwnProperty.call(NOTES, key) ? NOTES[key] : undefined;
|
|
85
|
+
const ignored = [...settings].map(([key, value]) => noteFor(key)?.(value, settings) ?? null).filter((n) => !!n);
|
|
71
86
|
return {
|
|
72
87
|
host,
|
|
73
88
|
port,
|
|
@@ -75,7 +90,7 @@ function parseRdpFile(data) {
|
|
|
75
90
|
...settings.get('domain') ? { domain: settings.get('domain') } : {},
|
|
76
91
|
...Number.isFinite(scale) && scale >= 100 ? { sharpness: scale >= 150 ? 'retina' : 'standard' } : {},
|
|
77
92
|
...usesGateway(settings) ? { gateway: settings.get('gatewayhostname') } : {},
|
|
93
|
+
...clipboardOff ? { clipboard: 'off' } : {},
|
|
78
94
|
ignored: [...new Set(ignored)],
|
|
79
95
|
};
|
|
80
96
|
}
|
|
81
|
-
//# sourceMappingURL=rdpFile.js.map
|