tabby-rdp 0.4.1 → 0.5.1

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 (76) hide show
  1. package/CHANGELOG.md +580 -0
  2. package/README.md +252 -34
  3. package/dist/accountForm.js +0 -1
  4. package/dist/accounts.js +0 -1
  5. package/dist/audio.js +0 -1
  6. package/dist/authorities.js +331 -0
  7. package/dist/byteQueue.js +102 -0
  8. package/dist/clipboard.js +106 -0
  9. package/dist/connectionStatus.js +0 -1
  10. package/dist/desk.js +16 -47
  11. package/dist/deskScript.js +196 -6
  12. package/dist/desktop.service.js +1763 -204
  13. package/dist/desktopForm.js +72 -13
  14. package/dist/desktops.js +243 -19
  15. package/dist/drives.js +525 -0
  16. package/dist/fileTransfer.js +1064 -72
  17. package/dist/gateway.js +651 -0
  18. package/dist/header.js +76 -5
  19. package/dist/help.js +34 -13
  20. package/dist/hyperv.js +129 -0
  21. package/dist/index.js +2 -2
  22. package/dist/keyboard.js +0 -1
  23. package/dist/microphone.js +57 -13
  24. package/dist/ntlm.js +225 -0
  25. package/dist/osd.js +56 -15
  26. package/dist/probe.js +15 -3
  27. package/dist/rdcleanpath.js +468 -52
  28. package/dist/rdpFile.js +53 -4
  29. package/dist/rdpProfile.js +75 -22
  30. package/dist/remoteSetup.js +103 -23
  31. package/dist/sessionLog.js +53 -0
  32. package/dist/settingsPage.js +117 -27
  33. package/dist/signin.js +429 -54
  34. package/dist/ssh.js +99 -18
  35. package/dist/sshChannelStream.js +50 -6
  36. package/dist/targets.js +185 -39
  37. package/dist/ui.js +98 -20
  38. package/dist/unshowable.js +41 -0
  39. package/dist/updates.js +37 -2
  40. package/dist/vms.js +36 -5
  41. package/dist/wake.js +31 -12
  42. package/package.json +4 -3
  43. package/remote/trd-pty.py +74 -13
  44. package/vendor/SHA256SUMS +26 -0
  45. package/vendor/iron-remote-desktop-rdp.js +418 -390
  46. package/vendor/ironrdp_web_bg.wasm +0 -0
  47. package/dist/accountForm.js.map +0 -1
  48. package/dist/accounts.js.map +0 -1
  49. package/dist/audio.js.map +0 -1
  50. package/dist/connectionStatus.js.map +0 -1
  51. package/dist/desk.js.map +0 -1
  52. package/dist/deskScript.js.map +0 -1
  53. package/dist/desktop.service.js.map +0 -1
  54. package/dist/desktopForm.js.map +0 -1
  55. package/dist/desktops.js.map +0 -1
  56. package/dist/fileTransfer.js.map +0 -1
  57. package/dist/header.js.map +0 -1
  58. package/dist/help.js.map +0 -1
  59. package/dist/index.js.map +0 -1
  60. package/dist/keyboard.js.map +0 -1
  61. package/dist/microphone.js.map +0 -1
  62. package/dist/osd.js.map +0 -1
  63. package/dist/probe.js.map +0 -1
  64. package/dist/rdcleanpath.js.map +0 -1
  65. package/dist/rdpFile.js.map +0 -1
  66. package/dist/rdpProfile.js.map +0 -1
  67. package/dist/remoteSetup.js.map +0 -1
  68. package/dist/settingsPage.js.map +0 -1
  69. package/dist/signin.js.map +0 -1
  70. package/dist/ssh.js.map +0 -1
  71. package/dist/sshChannelStream.js.map +0 -1
  72. package/dist/targets.js.map +0 -1
  73. package/dist/ui.js.map +0 -1
  74. package/dist/updates.js.map +0 -1
  75. package/dist/vms.js.map +0 -1
  76. package/dist/wake.js.map +0 -1
@@ -1,7 +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;
5
+ exports.preconnectionPdu = preconnectionPdu;
6
+ exports.offersNla = offersNla;
7
+ exports.earlyUserAuthorization = earlyUserAuthorization;
8
+ exports.withoutUserCookie = withoutUserCookie;
4
9
  exports.serverFraming = serverFraming;
10
+ exports.signsNothing = signsNothing;
11
+ exports.withoutToken = withoutToken;
5
12
  exports.startRDCleanPathProxy = startRDCleanPathProxy;
6
13
  // RDCleanPath proxy for the IronRDP web client, running inside Tabby's renderer.
7
14
  // The WASM client talks WebSocket to this proxy; the proxy opens the upstream stream
@@ -9,6 +16,9 @@ exports.startRDCleanPathProxy = startRDCleanPathProxy;
9
16
  // terminates TLS, checks the server's certificate, hands the cert chain back to the client (for CredSSP), then relays.
10
17
  const tls = require("tls");
11
18
  const crypto_1 = require("crypto");
19
+ const net_1 = require("net");
20
+ const authorities_1 = require("./authorities");
21
+ const byteQueue_1 = require("./byteQueue");
12
22
  // eslint-disable-next-line @typescript-eslint/no-var-requires
13
23
  const { WebSocketServer } = require('ws');
14
24
  const VERSION_1 = 3390;
@@ -21,6 +31,16 @@ function normalizeFingerprint(value) {
21
31
  function fingerprintOf(der) {
22
32
  return normalizeFingerprint((0, crypto_1.createHash)('sha256').update(der).digest('hex'));
23
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
+ }
24
44
  // ---- minimal DER -------------------------------------------------------------------------------
25
45
  function derLength(n) {
26
46
  if (n < 0x80) {
@@ -89,6 +109,9 @@ function decodeRequest(buf) {
89
109
  case 3:
90
110
  req.proxyAuth = value.toString('utf8');
91
111
  break;
112
+ case 5:
113
+ req.pcb = value.toString('utf8');
114
+ break;
92
115
  case 6:
93
116
  req.x224 = Buffer.from(value);
94
117
  break;
@@ -97,8 +120,24 @@ function decodeRequest(buf) {
97
120
  }
98
121
  return req;
99
122
  }
123
+ /** The response: the server's X.224 confirm (none for a Hyper-V VM's console), its certificate chain, its address. */
100
124
  function encodeResponse(x224, certChain, serverAddr) {
101
- return seq(explicit(0, derInteger(VERSION_1)), explicit(6, octets(x224)), explicit(7, seq(...certChain.map(octets))), explicit(9, utf8(serverAddr)));
125
+ return seq(explicit(0, derInteger(VERSION_1)), ...x224 ? [explicit(6, octets(x224))] : [], explicit(7, seq(...certChain.map(octets))), explicit(9, utf8(serverAddr)));
126
+ }
127
+ /**
128
+ * A version 2 pre-connection PDU ([MS-RDPEPS] 2.2.1.2) carrying `text`: what a client sends first to a server that
129
+ * routes by it, as Hyper-V does to a VM's console by the VM's id.
130
+ */
131
+ function preconnectionPdu(text) {
132
+ const blob = Buffer.from(text + '\0', 'utf16le');
133
+ const pdu = Buffer.alloc(18 + blob.length);
134
+ pdu.writeUInt32LE(pdu.length, 0); // cbSize
135
+ pdu.writeUInt32LE(0, 4); // Flags
136
+ pdu.writeUInt32LE(2, 8); // Version
137
+ pdu.writeUInt32LE(0, 12); // Id
138
+ pdu.writeUInt16LE(blob.length / 2, 16); // cchPCB, with the terminator
139
+ blob.copy(pdu, 18);
140
+ return pdu;
102
141
  }
103
142
  function encodeError() {
104
143
  return seq(explicit(0, derInteger(VERSION_1)), explicit(1, seq(explicit(0, derInteger(GENERAL_ERROR)))));
@@ -107,33 +146,46 @@ function encodeError() {
107
146
  // Reads exactly one TPKT-framed PDU (the X.224 Connection Confirm) without consuming TLS bytes.
108
147
  function readTpkt(stream) {
109
148
  return new Promise((resolve, reject) => {
110
- let acc = Buffer.alloc(0);
149
+ const acc = new byteQueue_1.ByteQueue();
111
150
  const cleanup = () => {
112
151
  stream.off('data', onData);
113
152
  stream.off('error', onError);
114
153
  stream.off('close', onClose);
154
+ stream.off('end', onClose);
115
155
  };
116
156
  const onError = (e) => { cleanup(); reject(e); };
117
157
  const onClose = () => { cleanup(); reject(new Error('upstream closed during X.224')); };
118
158
  const onData = (chunk) => {
119
- acc = Buffer.concat([acc, chunk]);
159
+ acc.push(chunk);
120
160
  if (acc.length < 4) {
121
161
  return;
122
162
  }
123
- 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
+ }
124
172
  if (acc.length < len) {
125
173
  return;
126
174
  }
127
175
  stream.pause();
128
176
  cleanup();
129
- if (acc.length > len) {
130
- 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));
131
181
  }
132
- resolve(acc.subarray(0, len));
182
+ resolve(pdu);
133
183
  };
134
184
  stream.on('data', onData);
135
185
  stream.on('error', onError);
136
186
  stream.on('close', onClose);
187
+ // An SSH channel says when the server has closed its side ("end") without always saying it is closed.
188
+ stream.on('end', onClose);
137
189
  });
138
190
  }
139
191
  function certChainOf(socket) {
@@ -147,6 +199,62 @@ function certChainOf(socket) {
147
199
  }
148
200
  return chain;
149
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;
205
+ /**
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.
209
+ */
210
+ function selectedProtocol(x224) {
211
+ return x224.length >= 19 && x224[11] === 0x02 ? x224.readUInt32LE(15) : 0;
212
+ }
213
+ /**
214
+ * Whether the server's answer to the connection request chose Network Level Authentication (CredSSP: HYBRID or
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.
216
+ * Without it (plain TLS, as xrdp does), the client sends the password itself in its Client Info PDU: encrypted on the
217
+ * way, readable to whatever answered.
218
+ */
219
+ function offersNla(x224) {
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;
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
+ }
150
258
  /**
151
259
  * The server's answer to the client's security negotiation, when it rules out TLS (which IronRDP and RDCleanPath
152
260
  * need): a server with RDP's standard security only (xrdp with security_layer=rdp, for example) answers without a
@@ -235,9 +343,11 @@ function autologon() {
235
343
  * function from server data to the PDUs it completes; it throws on data that is none of these.
236
344
  */
237
345
  function serverFraming(earlyAuth) {
238
- 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;
239
349
  let rdp = false;
240
- let pending = Buffer.alloc(0);
350
+ const pending = new byteQueue_1.ByteQueue();
241
351
  const invalid = () => new Error('invalid RDP server frame');
242
352
  /** The length of the PDU at the start of `b`, 0 while its header is incomplete. */
243
353
  const lengthOf = (b) => {
@@ -296,24 +406,23 @@ function serverFraming(earlyAuth) {
296
406
  throw invalid();
297
407
  };
298
408
  return data => {
299
- pending = pending.length ? Buffer.concat([pending, data]) : data;
409
+ pending.push(data);
300
410
  const out = [];
301
411
  for (;;) {
302
- 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);
303
415
  if (!length || pending.length < length) {
304
416
  break;
305
417
  }
306
- if (!rdp && earlyAuth && pending[0] !== 0x30) {
418
+ if (!rdp && earlyAuth && head[0] !== 0x30) {
307
419
  earlyAuth = false;
308
420
  }
309
- else if (pending[0] === 0x03) {
421
+ else if (head[0] === 0x03) {
310
422
  rdp = true;
311
423
  }
312
- out.push(pending.subarray(0, length));
313
- pending = pending.subarray(length);
424
+ out.push(pending.take(length));
314
425
  }
315
- // A copy of the rest: it would otherwise keep the whole chunk it came in alive.
316
- pending = Buffer.from(pending);
317
426
  return out;
318
427
  };
319
428
  }
@@ -328,22 +437,136 @@ const KEY_ENCIPHERMENT_ONLY_TLS = {
328
437
  maxVersion: 'TLSv1.2',
329
438
  ciphers: 'AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-SHA256:AES128-SHA256',
330
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
+ }
331
499
  class NoTlsError extends Error {
332
500
  }
333
- /** Sends the client's X.224 request upstream, reads the confirm, then does TLS there. */
334
- async function handshake(openUpstream, destination, x224Request, legacyTls) {
335
- const raw = await openUpstream(destination);
501
+ /** The client's first message (the RDCleanPath request: a destination, a token, an X.224 request) is small. */
502
+ const MAX_HELLO = 64 * 1024;
503
+ /** How long a client has to send it, and how long the server has to answer X.224 and TLS. */
504
+ const HELLO_TIMEOUT_MS = 10_000;
505
+ const HANDSHAKE_TIMEOUT_MS = 45_000;
506
+ /** What the client may send ahead while the server is being reached (CredSSP hasn't started: next to nothing). */
507
+ const MAX_EARLY = 256 * 1024;
508
+ /** One client at a time uses a proxy; a reconnect can overlap the connection it replaces. */
509
+ const MAX_CLIENTS = 4;
510
+ /** A WebSocket message from the client: RDP PDUs, at most a few megabytes for a shared drive's read. */
511
+ const MAX_MESSAGE = 32 * 1024 * 1024;
512
+ /** Data waiting for the other side past this pauses the side it comes from, until half of it is gone. */
513
+ const HIGH_WATER = 8 * 1024 * 1024;
514
+ /**
515
+ * Sends the client's X.224 request upstream, reads the confirm, then does TLS there. With a pre-connection blob, that
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
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
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).
520
+ */
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);
529
+ track(raw);
336
530
  try {
337
- raw.write(x224Request);
338
- const x224 = await readTpkt(raw);
339
- const refused = noTls(x224);
340
- if (refused) {
341
- throw new NoTlsError(refused);
531
+ if (gone()) {
532
+ throw new Error('the client left');
342
533
  }
343
- const upstream = tls.connect({ socket: raw, rejectUnauthorized: false, ...legacyTls ? KEY_ENCIPHERMENT_ONLY_TLS : {} });
534
+ if (pcb) {
535
+ raw.write(preconnectionPdu(pcb));
536
+ }
537
+ let x224 = null;
538
+ if (request) {
539
+ raw.write(request);
540
+ x224 = await readTpkt(raw);
541
+ const refused = noTls(x224);
542
+ if (refused) {
543
+ throw new NoTlsError(refused);
544
+ }
545
+ // Before TLS: a server that isn't to get the password gets nothing more at all.
546
+ if (!offersNla(x224)) {
547
+ await withoutNla();
548
+ }
549
+ }
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
+ });
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', () => { });
344
564
  await new Promise((resolve, reject) => {
345
565
  upstream.once('secureConnect', resolve);
346
- 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));
568
+ upstream.once('close', () => reject(new Error('upstream closed during TLS')));
569
+ upstream.once('end', () => reject(new Error('upstream closed during TLS')));
347
570
  });
348
571
  return { raw, upstream, x224 };
349
572
  }
@@ -352,28 +575,116 @@ async function handshake(openUpstream, destination, x224Request, legacyTls) {
352
575
  throw e;
353
576
  }
354
577
  }
355
- 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
+ }
356
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);
357
605
  let failure = null;
358
- // 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.
359
610
  let legacyTls = false;
360
611
  const stats = { bytesIn: 0, bytesOut: 0 };
361
- const wss = new WebSocketServer({ host: '127.0.0.1', port: 0 });
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
+ });
362
636
  await new Promise((resolve, reject) => {
363
637
  wss.once('listening', resolve);
364
638
  wss.once('error', reject);
365
639
  });
640
+ wss.on('error', (e) => log(`proxy: ${e.message}`));
366
641
  wss.on('connection', (ws) => {
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.
644
+ if (wss.clients.size > MAX_CLIENTS) {
645
+ ws.terminate();
646
+ return;
647
+ }
367
648
  let buf = Buffer.alloc(0);
368
649
  let stage = 'hello';
369
650
  const early = [];
651
+ let earlySize = 0;
370
652
  let upstream = null;
371
- let raw = null;
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();
656
+ // Every stream opened for this client, from the moment it exists: ended with the client.
657
+ const streams = new Set();
658
+ const track = (stream) => {
659
+ streams.add(stream);
660
+ stream.once('close', () => streams.delete(stream));
661
+ if (closed) {
662
+ stream.destroy();
663
+ }
664
+ };
665
+ const end = () => {
666
+ closed = true;
667
+ leaving.abort();
668
+ clearTimeout(deadline);
669
+ for (const stream of streams) {
670
+ stream.destroy();
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
+ }
678
+ };
679
+ let deadline = setTimeout(() => stage === 'hello' && ws.terminate(), HELLO_TIMEOUT_MS);
372
680
  const toServer = options.autologon ? autologon() : (data) => data;
373
681
  const send = (data) => {
374
682
  const out = toServer(data);
375
- if (out.length) {
376
- upstream.write(out);
683
+ // The server (or the SSH channel to it) not keeping up: stop reading from the client until it has (see
684
+ // ServerFlow).
685
+ if (out.length && !upstream.write(out) && upstream.writableLength > HIGH_WATER) {
686
+ ws.pause();
687
+ upstream.once('drain', () => ws.resume());
377
688
  }
378
689
  };
379
690
  failure = null;
@@ -385,47 +696,79 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
385
696
  }
386
697
  catch { }
387
698
  ws.close();
388
- upstream?.destroy();
389
- raw?.destroy();
699
+ end();
390
700
  };
701
+ ws.on('error', (e) => {
702
+ log(`proxy: client: ${e.message}`);
703
+ ws.terminate();
704
+ end();
705
+ });
391
706
  ws.on('message', async (data) => {
392
707
  if (stage === 'relay') {
393
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
+ }
394
714
  send(data);
395
715
  return;
396
716
  }
397
717
  if (stage === 'handshake') {
718
+ earlySize += data.length;
719
+ if (earlySize > MAX_EARLY) {
720
+ return fail('the client sent too much before the server was reached');
721
+ }
398
722
  early.push(data);
399
723
  return;
400
724
  }
725
+ // Before the token is checked: no more than a request's worth is kept, whatever length it claims.
726
+ if (buf.length + data.length > MAX_HELLO) {
727
+ ws.terminate();
728
+ return;
729
+ }
401
730
  buf = Buffer.concat([buf, data]);
402
731
  const outer = readTlv(buf, 0);
403
732
  if (!outer || buf.length < outer.end) {
404
733
  return;
405
734
  }
406
735
  stage = 'handshake';
736
+ clearTimeout(deadline);
407
737
  try {
408
738
  const req = decodeRequest(buf.subarray(0, outer.end));
409
- if (req.version !== VERSION_1 || !req.x224 || !req.destination) {
739
+ buf = Buffer.alloc(0);
740
+ if (req.version !== VERSION_1 || !req.destination || !req.x224 && !req.pcb?.trim()) {
410
741
  return fail('malformed request');
411
742
  }
412
- if (req.proxyAuth !== token) {
743
+ if (!sameSecret(req.proxyAuth ?? '', secret)) {
413
744
  return fail('bad token');
414
745
  }
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;
415
754
  let up;
416
755
  try {
417
- up = await handshake(openUpstream, req.destination, req.x224, legacyTls);
756
+ up = await handshake(openUpstream, req.destination, req.pcb, req.x224, legacy, track, () => closed, options.withoutNla, options, leaving.signal);
418
757
  }
419
758
  catch (e) {
420
- if (legacyTls || !/KEY_USAGE_BIT_INCORRECT/.test(e?.message ?? '')) {
759
+ if (closed || legacy || !(e instanceof KeyUsageRefused)) {
421
760
  throw e;
422
761
  }
423
762
  // The X.224 exchange is spent on that connection: start over on a fresh one.
424
763
  log('TLS: the server certificate only allows key encipherment; using TLS 1.2 with RSA key exchange');
425
- legacyTls = true;
426
- up = await handshake(openUpstream, req.destination, req.x224, legacyTls);
764
+ legacy = fellBack = true;
765
+ up = await handshake(openUpstream, req.destination, req.pcb, req.x224, legacy, track, () => closed, options.withoutNla, options, leaving.signal);
766
+ }
767
+ // Reached: from here on the time is the user's (a certificate to look at) and the session's.
768
+ clearTimeout(deadline);
769
+ if (closed) {
770
+ return;
427
771
  }
428
- raw = up.raw;
429
772
  upstream = up.upstream;
430
773
  const x224 = up.x224;
431
774
  // Both handshakes above, the first and the legacy-TLS retry, end here: the certificate is checked
@@ -435,11 +778,45 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
435
778
  if (!chain.length) {
436
779
  return fail('the server sent no certificate');
437
780
  }
438
- 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));
799
+ if (closed) {
800
+ return;
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
+ }
439
811
  ws.send(encodeResponse(x224, chain, req.destination));
440
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();
441
817
  // PROTOCOL_HYBRID_EX in the server's negotiation response: an Early User Authorization Result follows CredSSP.
442
- const frames = serverFraming(x224.length >= 19 && x224[11] === 0x02 && x224.readUInt32LE(15) === 8);
818
+ // (A Hyper-V VM's console: CredSSP first, then the X.224 exchange, which never asks for HYBRID_EX.)
819
+ const frames = serverFraming(!!x224 && earlyUserAuthorization(x224));
443
820
  upstream.on('data', (d) => {
444
821
  stats.bytesIn += d.length;
445
822
  let pdus;
@@ -455,8 +832,25 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
455
832
  for (const pdu of pdus) {
456
833
  ws.send(pdu);
457
834
  }
835
+ // The client not keeping up: stop reading from the server until it has taken half of it.
836
+ if (ws.bufferedAmount > HIGH_WATER && !upstream.isPaused()) {
837
+ upstream.pause();
838
+ const wait = setInterval(() => {
839
+ if (closed || ws.bufferedAmount < HIGH_WATER / 2) {
840
+ clearInterval(wait);
841
+ upstream?.resume();
842
+ }
843
+ }, 50);
844
+ }
458
845
  });
459
846
  upstream.on('close', () => ws.close());
847
+ // The server closing its side is the end of the desktop: RDP has no use for a connection open one way.
848
+ // Through an SSH host that is all there may be to hear (the channel reports the end, and "closed" only
849
+ // once something more is sent into it), and a desktop waiting for "closed" stayed up, frozen.
850
+ upstream.on('end', () => {
851
+ ws.close();
852
+ upstream?.destroy();
853
+ });
460
854
  upstream.on('error', (e) => { log(`upstream error: ${e.message}`); ws.close(); });
461
855
  for (const d of early) {
462
856
  stats.bytesOut += d.length;
@@ -465,21 +859,43 @@ async function startRDCleanPathProxy(openUpstream, checkCertificate, log = () =>
465
859
  log(`RDCleanPath relay up to ${req.destination}`);
466
860
  }
467
861
  catch (e) {
468
- fail(e?.message ?? String(e));
862
+ if (!closed) {
863
+ fail(e?.message ?? String(e));
864
+ }
469
865
  }
470
866
  });
471
- ws.on('close', () => {
472
- upstream?.destroy();
473
- raw?.destroy();
474
- });
867
+ ws.on('close', end);
475
868
  });
476
869
  const { port } = wss.address();
477
870
  return {
478
- url: `ws://127.0.0.1:${port}`,
871
+ url: `ws://127.0.0.1:${port}/${token}`,
479
872
  token,
480
873
  stats,
481
874
  get failure() { return failure; },
482
- close: () => wss.close(),
875
+ close: () => {
876
+ // Stops listening, and ends the clients there are: close() alone waits for them.
877
+ wss.close();
878
+ for (const client of wss.clients) {
879
+ client.terminate();
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
+ }
898
+ },
899
+ whenRoom: () => hasRoom() ? Promise.resolve() : new Promise(resolve => roomWaiters.push(resolve)),
483
900
  };
484
901
  }
485
- //# sourceMappingURL=rdcleanpath.js.map