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.
Files changed (87) hide show
  1. package/CHANGELOG.md +516 -0
  2. package/README.md +216 -58
  3. package/TROUBLESHOOTING.md +77 -0
  4. package/dist/accountForm.js +0 -1
  5. package/dist/accountRevisions.js +46 -0
  6. package/dist/accounts.js +2 -2
  7. package/dist/audio.js +0 -1
  8. package/dist/authorities.js +331 -0
  9. package/dist/byteQueue.js +102 -0
  10. package/dist/clipboard.js +106 -0
  11. package/dist/connectionStatus.js +0 -1
  12. package/dist/desk.js +16 -47
  13. package/dist/deskScript.js +196 -6
  14. package/dist/desktop.service.js +1499 -269
  15. package/dist/desktopForm.js +53 -18
  16. package/dist/desktops.js +232 -24
  17. package/dist/diskStorage.js +212 -0
  18. package/dist/drives.js +271 -30
  19. package/dist/fileTransfer.js +960 -132
  20. package/dist/gateway.js +216 -57
  21. package/dist/header.js +76 -5
  22. package/dist/help.js +16 -47
  23. package/dist/hostCompat.js +86 -0
  24. package/dist/hyperv.js +65 -15
  25. package/dist/index.js +7 -4
  26. package/dist/keyboard.js +0 -1
  27. package/dist/microphone.js +57 -13
  28. package/dist/ntlm.js +0 -1
  29. package/dist/osd.js +56 -15
  30. package/dist/packageChangeDialog.js +186 -0
  31. package/dist/probe.js +15 -3
  32. package/dist/rdcleanpath.js +319 -44
  33. package/dist/rdpFile.js +22 -7
  34. package/dist/rdpProfile.js +39 -21
  35. package/dist/releaseSettings.js +72 -0
  36. package/dist/releases.js +408 -0
  37. package/dist/remoteSetup.js +103 -23
  38. package/dist/sessionLog.js +53 -0
  39. package/dist/settingsPage.js +148 -122
  40. package/dist/signin.js +478 -48
  41. package/dist/ssh.js +99 -18
  42. package/dist/sshChannelStream.js +40 -6
  43. package/dist/targets.js +194 -40
  44. package/dist/ui.js +88 -20
  45. package/dist/unshowable.js +41 -0
  46. package/dist/updates.js +79 -56
  47. package/dist/vms.js +17 -3
  48. package/dist/wake.js +15 -8
  49. package/docs/desk.md +157 -0
  50. package/package.json +14 -4
  51. package/remote/trd-pty.py +74 -13
  52. package/vendor/SHA256SUMS +26 -0
  53. package/vendor/ironrdp_web_bg.wasm +0 -0
  54. package/dist/accountForm.js.map +0 -1
  55. package/dist/accounts.js.map +0 -1
  56. package/dist/audio.js.map +0 -1
  57. package/dist/connectionStatus.js.map +0 -1
  58. package/dist/desk.js.map +0 -1
  59. package/dist/deskScript.js.map +0 -1
  60. package/dist/desktop.service.js.map +0 -1
  61. package/dist/desktopForm.js.map +0 -1
  62. package/dist/desktops.js.map +0 -1
  63. package/dist/drives.js.map +0 -1
  64. package/dist/fileTransfer.js.map +0 -1
  65. package/dist/gateway.js.map +0 -1
  66. package/dist/header.js.map +0 -1
  67. package/dist/help.js.map +0 -1
  68. package/dist/hyperv.js.map +0 -1
  69. package/dist/index.js.map +0 -1
  70. package/dist/keyboard.js.map +0 -1
  71. package/dist/microphone.js.map +0 -1
  72. package/dist/ntlm.js.map +0 -1
  73. package/dist/osd.js.map +0 -1
  74. package/dist/probe.js.map +0 -1
  75. package/dist/rdcleanpath.js.map +0 -1
  76. package/dist/rdpFile.js.map +0 -1
  77. package/dist/rdpProfile.js.map +0 -1
  78. package/dist/remoteSetup.js.map +0 -1
  79. package/dist/settingsPage.js.map +0 -1
  80. package/dist/signin.js.map +0 -1
  81. package/dist/ssh.js.map +0 -1
  82. package/dist/sshChannelStream.js.map +0 -1
  83. package/dist/targets.js.map +0 -1
  84. package/dist/ui.js.map +0 -1
  85. package/dist/updates.js.map +0 -1
  86. package/dist/vms.js.map +0 -1
  87. package/dist/wake.js.map +0 -1
@@ -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
- let acc = Buffer.alloc(0);
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 = Buffer.concat([acc, chunk]);
159
+ acc.push(chunk);
142
160
  if (acc.length < 4) {
143
161
  return;
144
162
  }
145
- const len = acc.readUInt16BE(2);
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
- if (acc.length > len) {
152
- stream.unshift(acc.subarray(len));
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(acc.subarray(0, len));
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 server's answer to the client's security negotiation, when it rules out TLS (which IronRDP and RDCleanPath
176
- * need): a server with RDP's standard security only (xrdp with security_layer=rdp, for example) answers without a
177
- * negotiation response, or with PROTOCOL_RDP, or refuses the negotiation. Null when TLS can go ahead.
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 x224.length >= 19 && x224[11] === 0x02 && (x224.readUInt32LE(15) & (2 | 8)) !== 0;
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
- const MAX_DER = 16 * 1024 * 1024;
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
- let pending = Buffer.alloc(0);
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 = pending.length ? Buffer.concat([pending, data]) : data;
409
+ pending.push(data);
333
410
  const out = [];
334
411
  for (;;) {
335
- const length = lengthOf(pending);
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 && pending[0] !== 0x30) {
418
+ if (!rdp && earlyAuth && head[0] !== 0x30) {
340
419
  earlyAuth = false;
341
420
  }
342
- else if (pending[0] === 0x03) {
421
+ else if (head[0] === 0x03) {
343
422
  rdp = true;
344
423
  }
345
- out.push(pending.subarray(0, length));
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
- const raw = await openUpstream(destination);
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 (x224Request) {
397
- raw.write(x224Request);
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
- const upstream = tls.connect({ socket: raw, rejectUnauthorized: false, ...legacyTls ? KEY_ENCIPHERMENT_ONLY_TLS : {} });
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
- upstream.once('error', reject);
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
- async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () => { }, options = {}) {
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 the server turned out to need KEY_ENCIPHERMENT_ONLY_TLS; later connections start with it.
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
- const wss = new WebSocketServer({ host: '127.0.0.1', port: 0, maxPayload: MAX_MESSAGE });
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
- // Anything on this computer can connect to the port; only the client that knows the token gets further than
437
- // its first message, which has to be small and arrive soon.
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 !== token) {
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, legacyTls, track, () => closed, options.withoutNla);
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 || legacyTls || !/KEY_USAGE_BIT_INCORRECT/.test(e?.message ?? '')) {
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
- legacyTls = true;
537
- up = await handshake(openUpstream, req.destination, req.pcb, req.x224, legacyTls, track, () => closed, options.withoutNla);
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
- await checkCertificate(fingerprintOf(chain[0]));
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.length >= 19 && x224[11] === 0x02 && x224.readUInt32LE(15) === 8);
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' ? 'the microphone (Settings › Remote Desktop › Microphone)' : null,
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
- settings.set(m[1].toLowerCase(), m[3].trim());
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
- const m = /^\[([^\]]+)\](?::(\d+))?$|^([^:\s]+)(?::(\d+))?$/.exec(full);
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
- const ignored = [...settings].map(([key, value]) => NOTES[key]?.(value, settings) ?? null).filter((n) => !!n);
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