ai-remote 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +7 -0
- package/dist/protocols/index.js +35 -0
- package/dist/protocols/index.js.map +7 -0
- package/dist/protocols/rdp/buffer.js +170 -0
- package/dist/protocols/rdp/buffer.js.map +7 -0
- package/dist/protocols/rdp/caps.js +260 -0
- package/dist/protocols/rdp/caps.js.map +7 -0
- package/dist/protocols/rdp/cert.js +135 -0
- package/dist/protocols/rdp/cert.js.map +7 -0
- package/dist/protocols/rdp/client.js +1068 -0
- package/dist/protocols/rdp/client.js.map +7 -0
- package/dist/protocols/rdp/cliprdr.js +240 -0
- package/dist/protocols/rdp/cliprdr.js.map +7 -0
- package/dist/protocols/rdp/credssp.js +254 -0
- package/dist/protocols/rdp/credssp.js.map +7 -0
- package/dist/protocols/rdp/crypto.js +369 -0
- package/dist/protocols/rdp/crypto.js.map +7 -0
- package/dist/protocols/rdp/display.js +589 -0
- package/dist/protocols/rdp/display.js.map +7 -0
- package/dist/protocols/rdp/gcc.js +132 -0
- package/dist/protocols/rdp/gcc.js.map +7 -0
- package/dist/protocols/rdp/keymap.js +132 -0
- package/dist/protocols/rdp/keymap.js.map +7 -0
- package/dist/protocols/rdp/mcs.js +223 -0
- package/dist/protocols/rdp/mcs.js.map +7 -0
- package/dist/protocols/rdp/ntlm.js +305 -0
- package/dist/protocols/rdp/ntlm.js.map +7 -0
- package/dist/protocols/rdp/pdu.js +444 -0
- package/dist/protocols/rdp/pdu.js.map +7 -0
- package/dist/protocols/rdp/rail.js +383 -0
- package/dist/protocols/rdp/rail.js.map +7 -0
- package/dist/protocols/rdp/rle.js +276 -0
- package/dist/protocols/rdp/rle.js.map +7 -0
- package/dist/protocols/rdp/sec.js +156 -0
- package/dist/protocols/rdp/sec.js.map +7 -0
- package/dist/protocols/rdp/session.js +307 -0
- package/dist/protocols/rdp/session.js.map +7 -0
- package/dist/protocols/rdp/tls.js +354 -0
- package/dist/protocols/rdp/tls.js.map +7 -0
- package/dist/protocols/rdp/vchannel.js +59 -0
- package/dist/protocols/rdp/vchannel.js.map +7 -0
- package/dist/protocols/rdp/x224.js +110 -0
- package/dist/protocols/rdp/x224.js.map +7 -0
- package/dist/protocols/ssh/kex.js +177 -0
- package/dist/protocols/ssh/kex.js.map +7 -0
- package/dist/protocols/ssh/messages.js +55 -0
- package/dist/protocols/ssh/messages.js.map +7 -0
- package/dist/protocols/ssh/session.js +608 -0
- package/dist/protocols/ssh/session.js.map +7 -0
- package/dist/protocols/ssh/terminal.js +191 -0
- package/dist/protocols/ssh/terminal.js.map +7 -0
- package/dist/protocols/ssh/transport.js +611 -0
- package/dist/protocols/ssh/transport.js.map +7 -0
- package/dist/protocols/ssh/wire.js +189 -0
- package/dist/protocols/ssh/wire.js.map +7 -0
- package/dist/protocols/types.js +1 -0
- package/dist/protocols/types.js.map +7 -0
- package/dist/protocols/vnc/input.js +16 -0
- package/dist/protocols/vnc/input.js.map +7 -0
- package/dist/protocols/vnc/session.js +259 -0
- package/dist/protocols/vnc/session.js.map +7 -0
- package/dist/shared/connection.js +114 -0
- package/dist/shared/connection.js.map +7 -0
- package/dist/shared/device.js +1 -0
- package/dist/shared/device.js.map +7 -0
- package/dist/shared/hosts.js +133 -0
- package/dist/shared/hosts.js.map +7 -0
- package/dist/shared/icons.js +42 -0
- package/dist/shared/icons.js.map +7 -0
- package/dist/shared/protocol.js +40 -0
- package/dist/shared/protocol.js.map +7 -0
- package/dist/shared/signals.js +42 -0
- package/dist/shared/signals.js.map +7 -0
- package/package.json +82 -0
- package/src/index.ts +14 -0
- package/src/protocols/index.ts +72 -0
- package/src/protocols/rdp/buffer.ts +204 -0
- package/src/protocols/rdp/caps.ts +304 -0
- package/src/protocols/rdp/cert.ts +190 -0
- package/src/protocols/rdp/client.ts +1363 -0
- package/src/protocols/rdp/cliprdr.ts +344 -0
- package/src/protocols/rdp/credssp.ts +319 -0
- package/src/protocols/rdp/crypto.ts +416 -0
- package/src/protocols/rdp/display.ts +750 -0
- package/src/protocols/rdp/gcc.ts +162 -0
- package/src/protocols/rdp/keymap.ts +68 -0
- package/src/protocols/rdp/mcs.ts +283 -0
- package/src/protocols/rdp/ntlm.ts +380 -0
- package/src/protocols/rdp/pdu.ts +497 -0
- package/src/protocols/rdp/rail.ts +480 -0
- package/src/protocols/rdp/rle.ts +343 -0
- package/src/protocols/rdp/sec.ts +184 -0
- package/src/protocols/rdp/session.ts +393 -0
- package/src/protocols/rdp/tls.ts +452 -0
- package/src/protocols/rdp/vchannel.ts +89 -0
- package/src/protocols/rdp/x224.ts +139 -0
- package/src/protocols/ssh/kex.ts +227 -0
- package/src/protocols/ssh/messages.ts +54 -0
- package/src/protocols/ssh/session.ts +878 -0
- package/src/protocols/ssh/terminal.ts +242 -0
- package/src/protocols/ssh/transport.ts +732 -0
- package/src/protocols/ssh/wire.ts +220 -0
- package/src/protocols/types.ts +146 -0
- package/src/protocols/vnc/input.ts +13 -0
- package/src/protocols/vnc/novnc.d.ts +75 -0
- package/src/protocols/vnc/session.ts +367 -0
- package/src/shared/connection.ts +206 -0
- package/src/shared/device.ts +28 -0
- package/src/shared/hosts.ts +240 -0
- package/src/shared/icons.ts +49 -0
- package/src/shared/protocol.ts +55 -0
- package/src/shared/signals.ts +67 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
// Clipboard redirection (MS-RDPECLIP) on the "cliprdr" virtual channel.
|
|
2
|
+
//
|
|
3
|
+
// Text only, and deliberately so: CF_UNICODETEXT is what copy and paste between
|
|
4
|
+
// a browser and a desktop actually needs, and the file-contents formats would
|
|
5
|
+
// mean presenting a filesystem to the host.
|
|
6
|
+
//
|
|
7
|
+
// RDP clipboards use delayed rendering. Whoever just copied something only
|
|
8
|
+
// announces which formats it holds; the bytes are fetched by the other side at
|
|
9
|
+
// paste time. Both halves of that exchange live here:
|
|
10
|
+
//
|
|
11
|
+
// host copied host -> FORMAT_LIST, we -> FORMAT_DATA_REQUEST,
|
|
12
|
+
// host -> FORMAT_DATA_RESPONSE, we emit 'text'
|
|
13
|
+
// we copied we -> FORMAT_LIST, host -> FORMAT_DATA_REQUEST,
|
|
14
|
+
// we -> FORMAT_DATA_RESPONSE from the cached text
|
|
15
|
+
|
|
16
|
+
import { Reader, Writer, concatBytes } from './buffer';
|
|
17
|
+
|
|
18
|
+
/** CLIPRDR_HEADER msgType (MS-RDPECLIP 2.2.1). */
|
|
19
|
+
const MSG = {
|
|
20
|
+
MONITOR_READY: 0x0001,
|
|
21
|
+
FORMAT_LIST: 0x0002,
|
|
22
|
+
FORMAT_LIST_RESPONSE: 0x0003,
|
|
23
|
+
FORMAT_DATA_REQUEST: 0x0004,
|
|
24
|
+
FORMAT_DATA_RESPONSE: 0x0005,
|
|
25
|
+
CLIP_CAPS: 0x0007,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const RESPONSE_OK = 0x0001;
|
|
29
|
+
const RESPONSE_FAIL = 0x0002;
|
|
30
|
+
const ASCII_NAMES = 0x0004;
|
|
31
|
+
|
|
32
|
+
/** Standard Windows clipboard format ids. */
|
|
33
|
+
const CF_TEXT = 1;
|
|
34
|
+
const CF_UNICODETEXT = 13;
|
|
35
|
+
|
|
36
|
+
const CAPSTYPE_GENERAL = 0x0001;
|
|
37
|
+
const CAPS_VERSION_2 = 0x00000002;
|
|
38
|
+
const USE_LONG_FORMAT_NAMES = 0x00000002;
|
|
39
|
+
|
|
40
|
+
/** Short format names are a fixed-width field, long ones are NUL-terminated. */
|
|
41
|
+
const SHORT_FORMAT_NAME_BYTES = 32;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* How long to wait for the host to acknowledge a format list before pasting
|
|
45
|
+
* anyway. The ack is what proves the host has invalidated its own clipboard, so
|
|
46
|
+
* waiting for it is what keeps a paste from delivering the previous contents.
|
|
47
|
+
*/
|
|
48
|
+
const FORMAT_LIST_ACK_TIMEOUT_MS = 400;
|
|
49
|
+
|
|
50
|
+
function cliprdrPdu(msgType, msgFlags: number, body = new Uint8Array(0)) {
|
|
51
|
+
const writer = new Writer(body.length + 8);
|
|
52
|
+
writer.u16le(msgType).u16le(msgFlags).u32le(body.length).raw(body);
|
|
53
|
+
return writer.take();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** UTF-16LE with the terminating NUL the format expects. */
|
|
57
|
+
function encodeUnicodeText(text: string) {
|
|
58
|
+
const writer = new Writer(text.length * 2 + 2);
|
|
59
|
+
writer.utf16le(text);
|
|
60
|
+
writer.u16le(0);
|
|
61
|
+
return writer.take();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function decodeUnicodeText(bytes: Uint8Array) {
|
|
65
|
+
const units = new Uint16Array(bytes.length >> 1);
|
|
66
|
+
for (let i = 0; i < units.length; i++) {
|
|
67
|
+
units[i] = bytes[i * 2] | (bytes[i * 2 + 1] << 8);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Everything from the first NUL on is padding, not content.
|
|
71
|
+
let end = units.indexOf(0);
|
|
72
|
+
if (end === -1) end = units.length;
|
|
73
|
+
|
|
74
|
+
let text = '';
|
|
75
|
+
// Chunked so a large paste cannot overflow the argument list.
|
|
76
|
+
for (let i = 0; i < end; i += 4096) {
|
|
77
|
+
text += String.fromCharCode(...units.subarray(i, Math.min(i + 4096, end)));
|
|
78
|
+
}
|
|
79
|
+
return text;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function encodeAsciiText(text: string) {
|
|
83
|
+
const writer = new Writer(text.length + 1);
|
|
84
|
+
for (const character of text) {
|
|
85
|
+
const code = character.codePointAt(0);
|
|
86
|
+
writer.u8(code > 0xff ? 0x3f : code); // '?' for anything Latin-1 cannot hold
|
|
87
|
+
}
|
|
88
|
+
writer.u8(0);
|
|
89
|
+
return writer.take();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function decodeAsciiText(bytes: Uint8Array) {
|
|
93
|
+
let end = bytes.indexOf(0);
|
|
94
|
+
if (end === -1) end = bytes.length;
|
|
95
|
+
return String.fromCharCode(...bytes.subarray(0, end));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One clipboard channel. Emits:
|
|
100
|
+
* 'text' { text } -- the host's clipboard now holds this text
|
|
101
|
+
*/
|
|
102
|
+
export class ClipboardChannel extends EventTarget {
|
|
103
|
+
private readonly send: (bytes: Uint8Array) => void;
|
|
104
|
+
private readonly log: (message: string, detail?: unknown) => void;
|
|
105
|
+
private ready: boolean;
|
|
106
|
+
private longFormatNames: boolean;
|
|
107
|
+
private localText: string;
|
|
108
|
+
private pendingAck: { resolve: (acknowledged: boolean) => void } | null;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {(bytes: Uint8Array) => void} send delivers one complete channel message
|
|
112
|
+
* @param {(step: string, detail?: unknown) => void} [log]
|
|
113
|
+
*/
|
|
114
|
+
constructor({ send, log }) {
|
|
115
|
+
super();
|
|
116
|
+
|
|
117
|
+
this.send = send;
|
|
118
|
+
this.log = log || (() => {});
|
|
119
|
+
this.ready = false;
|
|
120
|
+
this.longFormatNames = true;
|
|
121
|
+
|
|
122
|
+
/** The text this side hands over when the host pastes. */
|
|
123
|
+
this.localText = '';
|
|
124
|
+
/** Resolves when the host acknowledges our most recent format list. */
|
|
125
|
+
this.pendingAck = null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#sendPdu(msgType, msgFlags: number, body: Uint8Array) {
|
|
129
|
+
this.send(cliprdrPdu(msgType, msgFlags, body));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- outbound ------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Announce that this side now owns the clipboard and holds `text`. Resolves
|
|
136
|
+
* once the host has acknowledged, so a paste keystroke can safely follow.
|
|
137
|
+
*/
|
|
138
|
+
setLocalText(text: string) {
|
|
139
|
+
this.localText = text;
|
|
140
|
+
if (!this.ready) return Promise.resolve(false);
|
|
141
|
+
|
|
142
|
+
this.#sendPdu(MSG.FORMAT_LIST, 0, this.#formatListBody(
|
|
143
|
+
text ? [CF_UNICODETEXT, CF_TEXT] : []
|
|
144
|
+
));
|
|
145
|
+
|
|
146
|
+
// Only the newest list matters; an earlier waiter would paste stale text.
|
|
147
|
+
if (this.pendingAck) this.pendingAck.resolve(false);
|
|
148
|
+
|
|
149
|
+
return new Promise((resolve) => {
|
|
150
|
+
let timer = 0;
|
|
151
|
+
const settle = (acknowledged: boolean) => {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
if (this.pendingAck?.resolve === settle) this.pendingAck = null;
|
|
154
|
+
resolve(acknowledged);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
timer = setTimeout(() => {
|
|
158
|
+
this.log('clipboard format list was not acknowledged');
|
|
159
|
+
settle(false);
|
|
160
|
+
}, FORMAT_LIST_ACK_TIMEOUT_MS);
|
|
161
|
+
|
|
162
|
+
this.pendingAck = { resolve: settle };
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#formatListBody(formatIds) {
|
|
167
|
+
const parts = formatIds.map((formatId) => {
|
|
168
|
+
const writer = new Writer(this.longFormatNames ? 6 : 4 + SHORT_FORMAT_NAME_BYTES);
|
|
169
|
+
writer.u32le(formatId);
|
|
170
|
+
// Standard formats have no name; long lists still need its terminator.
|
|
171
|
+
if (this.longFormatNames) writer.u16le(0);
|
|
172
|
+
else writer.zeros(SHORT_FORMAT_NAME_BYTES);
|
|
173
|
+
return writer.take();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return concatBytes(...parts);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// --- inbound -------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
receive(bytes: Uint8Array) {
|
|
182
|
+
if (bytes.length < 8) return;
|
|
183
|
+
|
|
184
|
+
const reader = new Reader(bytes);
|
|
185
|
+
const msgType = reader.u16le();
|
|
186
|
+
const msgFlags = reader.u16le();
|
|
187
|
+
const dataLength = reader.u32le();
|
|
188
|
+
const rest = reader.rest();
|
|
189
|
+
const body = rest.subarray(0, Math.min(dataLength, rest.length));
|
|
190
|
+
|
|
191
|
+
switch (msgType) {
|
|
192
|
+
case MSG.CLIP_CAPS:
|
|
193
|
+
this.#handleCapabilities(body);
|
|
194
|
+
return;
|
|
195
|
+
|
|
196
|
+
case MSG.MONITOR_READY:
|
|
197
|
+
this.#handleMonitorReady();
|
|
198
|
+
return;
|
|
199
|
+
|
|
200
|
+
case MSG.FORMAT_LIST:
|
|
201
|
+
this.#handleFormatList(body, msgFlags);
|
|
202
|
+
return;
|
|
203
|
+
|
|
204
|
+
case MSG.FORMAT_LIST_RESPONSE:
|
|
205
|
+
// A failed ack still unblocks a waiting paste: the keystroke is more
|
|
206
|
+
// useful than a silent stall.
|
|
207
|
+
this.pendingAck?.resolve((msgFlags & RESPONSE_FAIL) === 0);
|
|
208
|
+
return;
|
|
209
|
+
|
|
210
|
+
case MSG.FORMAT_DATA_REQUEST:
|
|
211
|
+
this.#handleFormatDataRequest(body);
|
|
212
|
+
return;
|
|
213
|
+
|
|
214
|
+
case MSG.FORMAT_DATA_RESPONSE:
|
|
215
|
+
this.#handleFormatDataResponse(body, msgFlags);
|
|
216
|
+
return;
|
|
217
|
+
|
|
218
|
+
default:
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#handleCapabilities(body: Uint8Array) {
|
|
224
|
+
const reader = new Reader(body);
|
|
225
|
+
if (reader.remaining < 4) return;
|
|
226
|
+
|
|
227
|
+
const setCount = reader.u16le();
|
|
228
|
+
reader.u16le(); // pad
|
|
229
|
+
|
|
230
|
+
for (let i = 0; i < setCount && reader.remaining >= 4; i++) {
|
|
231
|
+
const type = reader.u16le();
|
|
232
|
+
const length = reader.u16le();
|
|
233
|
+
if (length < 4 || length - 4 > reader.remaining) break;
|
|
234
|
+
const set = new Reader(reader.raw(length - 4));
|
|
235
|
+
|
|
236
|
+
if (type === CAPSTYPE_GENERAL && set.remaining >= 8) {
|
|
237
|
+
set.u32le(); // version
|
|
238
|
+
const flags = set.u32le();
|
|
239
|
+
// Both sides have to agree, so the host's answer decides.
|
|
240
|
+
this.longFormatNames = (flags & USE_LONG_FORMAT_NAMES) !== 0;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The host is ready. The client answers with its own capabilities and an
|
|
247
|
+
* initial format list, which MS-RDPECLIP 1.3.2.1 requires even when there is
|
|
248
|
+
* nothing to offer yet.
|
|
249
|
+
*/
|
|
250
|
+
#handleMonitorReady() {
|
|
251
|
+
const general = new Writer(12);
|
|
252
|
+
general.u16le(CAPSTYPE_GENERAL).u16le(12);
|
|
253
|
+
general.u32le(CAPS_VERSION_2);
|
|
254
|
+
general.u32le(this.longFormatNames ? USE_LONG_FORMAT_NAMES : 0);
|
|
255
|
+
|
|
256
|
+
const caps = new Writer(20);
|
|
257
|
+
caps.u16le(1).u16le(0); // one capability set, then padding
|
|
258
|
+
caps.raw(general.take());
|
|
259
|
+
|
|
260
|
+
this.#sendPdu(MSG.CLIP_CAPS, 0, caps.take());
|
|
261
|
+
this.ready = true;
|
|
262
|
+
this.#sendPdu(MSG.FORMAT_LIST, 0, this.#formatListBody(
|
|
263
|
+
this.localText ? [CF_UNICODETEXT, CF_TEXT] : []
|
|
264
|
+
));
|
|
265
|
+
|
|
266
|
+
this.log('clipboard channel ready', { longFormatNames: this.longFormatNames });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#handleFormatList(body: Uint8Array, msgFlags: number) {
|
|
270
|
+
const formats = this.#parseFormatList(body, msgFlags);
|
|
271
|
+
this.#sendPdu(MSG.FORMAT_LIST_RESPONSE, RESPONSE_OK);
|
|
272
|
+
|
|
273
|
+
// Unicode first: it is the only format that survives every character.
|
|
274
|
+
const wanted = formats.includes(CF_UNICODETEXT)
|
|
275
|
+
? CF_UNICODETEXT
|
|
276
|
+
: formats.includes(CF_TEXT) ? CF_TEXT : null;
|
|
277
|
+
|
|
278
|
+
if (wanted === null) {
|
|
279
|
+
this.log('host clipboard holds no text format', { formats });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const request = new Writer(4);
|
|
284
|
+
request.u32le(wanted);
|
|
285
|
+
this.#sendPdu(MSG.FORMAT_DATA_REQUEST, 0, request.take());
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#parseFormatList(body: Uint8Array, msgFlags: number) {
|
|
289
|
+
const reader = new Reader(body);
|
|
290
|
+
const formats = [];
|
|
291
|
+
|
|
292
|
+
// A short list is a fixed 36 bytes per entry; a long one is variable, so
|
|
293
|
+
// the encoding has to be known rather than guessed. ASCII names are always
|
|
294
|
+
// the short form (MS-RDPECLIP 2.2.3.1.1.1).
|
|
295
|
+
const short = !this.longFormatNames || (msgFlags & ASCII_NAMES) !== 0;
|
|
296
|
+
|
|
297
|
+
while (reader.remaining >= 4) {
|
|
298
|
+
formats.push(reader.u32le());
|
|
299
|
+
|
|
300
|
+
if (short) {
|
|
301
|
+
if (reader.remaining < SHORT_FORMAT_NAME_BYTES) break;
|
|
302
|
+
reader.skip(SHORT_FORMAT_NAME_BYTES);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Long names are UTF-16LE up to and including a NUL unit.
|
|
307
|
+
while (reader.remaining >= 2 && reader.u16le() !== 0) { /* skip the name */ }
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return formats;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#handleFormatDataRequest(body: Uint8Array) {
|
|
314
|
+
const reader = new Reader(body);
|
|
315
|
+
const formatId = reader.remaining >= 4 ? reader.u32le() : 0;
|
|
316
|
+
|
|
317
|
+
if (formatId !== CF_UNICODETEXT && formatId !== CF_TEXT) {
|
|
318
|
+
this.#sendPdu(MSG.FORMAT_DATA_RESPONSE, RESPONSE_FAIL);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const data = formatId === CF_UNICODETEXT
|
|
323
|
+
? encodeUnicodeText(this.localText)
|
|
324
|
+
: encodeAsciiText(this.localText);
|
|
325
|
+
|
|
326
|
+
this.#sendPdu(MSG.FORMAT_DATA_RESPONSE, RESPONSE_OK, data);
|
|
327
|
+
this.log('handed the host our clipboard', { formatId, characters: this.localText.length });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#handleFormatDataResponse(body: Uint8Array, msgFlags: number) {
|
|
331
|
+
if (msgFlags & RESPONSE_FAIL) {
|
|
332
|
+
this.log('the host refused to hand over its clipboard');
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Whichever format was asked for, a UTF-16 payload has a zero in every
|
|
337
|
+
// second byte; that is what tells the two apart on the way back.
|
|
338
|
+
const looksUnicode = body.length >= 2 && body.length % 2 === 0 && body[1] === 0;
|
|
339
|
+
const text = looksUnicode ? decodeUnicodeText(body) : decodeAsciiText(body);
|
|
340
|
+
if (!text) return;
|
|
341
|
+
|
|
342
|
+
this.dispatchEvent(new CustomEvent('text', { detail: { text } }));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// CredSSP (MS-CSSP): the protocol behind RDP's Network Level Authentication.
|
|
2
|
+
//
|
|
3
|
+
// It runs inside the TLS channel and does three things in order: authenticate
|
|
4
|
+
// with NTLM, prove to the host that the TLS certificate we were shown is the
|
|
5
|
+
// one the host actually holds, and only then hand over the credentials. The
|
|
6
|
+
// second step is why this gateway needs its own TLS client -- the proof is
|
|
7
|
+
// computed over the host certificate's public key, which no runtime here
|
|
8
|
+
// exposes for a TLS session it established on our behalf.
|
|
9
|
+
|
|
10
|
+
import { concatBytes } from './buffer';
|
|
11
|
+
import { NtlmSealer, authenticateMessage, negotiateMessage, parseChallengeMessage } from './ntlm';
|
|
12
|
+
|
|
13
|
+
const CREDSSP_VERSION = 6;
|
|
14
|
+
|
|
15
|
+
// MS-CSSP 3.1.5: from version 5 the binding is a hash over a client nonce and
|
|
16
|
+
// the public key, rather than the bare public key.
|
|
17
|
+
const CLIENT_TO_SERVER_MAGIC = 'CredSSP Client-To-Server Binding Hash\0';
|
|
18
|
+
const SERVER_TO_CLIENT_MAGIC = 'CredSSP Server-To-Client Binding Hash\0';
|
|
19
|
+
|
|
20
|
+
const NT_STATUS_MESSAGES = {
|
|
21
|
+
0xc000006d: 'The host rejected the username or password (STATUS_LOGON_FAILURE).',
|
|
22
|
+
0xc000006e: 'The account is not allowed to sign in right now (STATUS_ACCOUNT_RESTRICTION).',
|
|
23
|
+
0xc000006f: 'The account is outside its permitted logon hours.',
|
|
24
|
+
0xc0000070: 'The account is not allowed to sign in from this machine (STATUS_INVALID_WORKSTATION).',
|
|
25
|
+
0xc0000071: 'The account password has expired.',
|
|
26
|
+
0xc0000072: 'The account is disabled.',
|
|
27
|
+
0xc000015b: 'The account is not allowed the Remote Desktop logon type -- add it to the Remote Desktop Users group.',
|
|
28
|
+
0xc0000193: 'The account has expired.',
|
|
29
|
+
0xc0000224: 'The account must change its password before signing in.',
|
|
30
|
+
0xc0000234: 'The account is locked out.',
|
|
31
|
+
0x8009030c: 'The host denied the logon (SEC_E_LOGON_DENIED).',
|
|
32
|
+
0x80090308: 'The host rejected the authentication token (SEC_E_INVALID_TOKEN).',
|
|
33
|
+
0x80090302: 'The host rejected the CredSSP exchange (SEC_E_UNSUPPORTED_FUNCTION) -- usually a public-key binding mismatch.',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// --- DER --------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
function derLength(length: number) {
|
|
39
|
+
if (length < 0x80) return new Uint8Array([length]);
|
|
40
|
+
if (length <= 0xff) return new Uint8Array([0x81, length]);
|
|
41
|
+
if (length <= 0xffff) return new Uint8Array([0x82, length >> 8, length & 0xff]);
|
|
42
|
+
return new Uint8Array([0x83, (length >> 16) & 0xff, (length >> 8) & 0xff, length & 0xff]);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const derTlv = (tag, content: Uint8Array) => concatBytes(new Uint8Array([tag]), derLength(content.length), content);
|
|
46
|
+
const derSequence = (...parts) => derTlv(0x30, concatBytes(...parts));
|
|
47
|
+
const derOctetString = (bytes: Uint8Array) => derTlv(0x04, bytes);
|
|
48
|
+
const derContext = (index, content: Uint8Array) => derTlv(0xa0 | index, content);
|
|
49
|
+
|
|
50
|
+
function derInteger(value) {
|
|
51
|
+
const bytes = [];
|
|
52
|
+
let remaining = value;
|
|
53
|
+
do {
|
|
54
|
+
bytes.unshift(remaining & 0xff);
|
|
55
|
+
remaining >>>= 8;
|
|
56
|
+
} while (remaining > 0);
|
|
57
|
+
// A leading bit of 1 would read as negative.
|
|
58
|
+
if (bytes[0] & 0x80) bytes.unshift(0x00);
|
|
59
|
+
return derTlv(0x02, new Uint8Array(bytes));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function derRead(bytes: Uint8Array, offset: number) {
|
|
63
|
+
const tag = bytes[offset];
|
|
64
|
+
let cursor = offset + 1;
|
|
65
|
+
let length = bytes[cursor++];
|
|
66
|
+
|
|
67
|
+
if (length & 0x80) {
|
|
68
|
+
const count = length & 0x7f;
|
|
69
|
+
length = 0;
|
|
70
|
+
for (let i = 0; i < count; i++) length = (length << 8) | bytes[cursor++];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { tag, content: bytes.subarray(cursor, cursor + length), end: cursor + length };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Total encoded length of the DER element starting at `offset`, or 0 if partial. */
|
|
77
|
+
function derElementLength(bytes: Uint8Array, offset = 0) {
|
|
78
|
+
if (bytes.length - offset < 2) return 0;
|
|
79
|
+
let cursor = offset + 1;
|
|
80
|
+
let length = bytes[cursor++];
|
|
81
|
+
|
|
82
|
+
if (length & 0x80) {
|
|
83
|
+
const count = length & 0x7f;
|
|
84
|
+
if (bytes.length - cursor < count) return 0;
|
|
85
|
+
length = 0;
|
|
86
|
+
for (let i = 0; i < count; i++) length = (length << 8) | bytes[cursor++];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const total = cursor - offset + length;
|
|
90
|
+
return bytes.length - offset < total ? 0 : total;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function encodeTsRequest({ version, negoToken, authInfo, pubKeyAuth, clientNonce }) {
|
|
94
|
+
const parts = [derContext(0, derInteger(version))];
|
|
95
|
+
|
|
96
|
+
if (negoToken) {
|
|
97
|
+
parts.push(derContext(1, derSequence(derSequence(derContext(0, derOctetString(negoToken))))));
|
|
98
|
+
}
|
|
99
|
+
if (authInfo) parts.push(derContext(2, derOctetString(authInfo)));
|
|
100
|
+
if (pubKeyAuth) parts.push(derContext(3, derOctetString(pubKeyAuth)));
|
|
101
|
+
if (clientNonce) parts.push(derContext(5, derOctetString(clientNonce)));
|
|
102
|
+
|
|
103
|
+
return derSequence(...parts);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function decodeTsRequest(bytes: Uint8Array) {
|
|
107
|
+
const outer = derRead(bytes, 0);
|
|
108
|
+
const result = { version: 0, negoToken: null, authInfo: null, pubKeyAuth: null, errorCode: 0 };
|
|
109
|
+
|
|
110
|
+
let offset = 0;
|
|
111
|
+
while (offset < outer.content.length) {
|
|
112
|
+
const field = derRead(outer.content, offset);
|
|
113
|
+
offset = field.end;
|
|
114
|
+
const index = field.tag & 0x1f;
|
|
115
|
+
|
|
116
|
+
if (index === 0) {
|
|
117
|
+
result.version = derRead(field.content, 0).content[0];
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (index === 1) {
|
|
121
|
+
const sequence = derRead(field.content, 0); // SEQUENCE OF
|
|
122
|
+
const item = derRead(sequence.content, 0); // SEQUENCE
|
|
123
|
+
const token = derRead(item.content, 0); // [0] OCTET STRING
|
|
124
|
+
result.negoToken = derRead(token.content, 0).content;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (index === 2) {
|
|
128
|
+
result.authInfo = derRead(field.content, 0).content;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (index === 3) {
|
|
132
|
+
result.pubKeyAuth = derRead(field.content, 0).content;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (index === 4) {
|
|
136
|
+
const value = derRead(field.content, 0).content;
|
|
137
|
+
let code = 0;
|
|
138
|
+
for (const byte of value) code = ((code << 8) | byte) >>> 0;
|
|
139
|
+
result.errorCode = code;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** TSCredentials wrapping TSPasswordCreds (MS-CSSP 2.2.1.2). */
|
|
148
|
+
function encodeCredentials(domain: string, username: string, password: string) {
|
|
149
|
+
const utf16 = (text: string) => {
|
|
150
|
+
const out = new Uint8Array(text.length * 2);
|
|
151
|
+
const view = new DataView(out.buffer);
|
|
152
|
+
for (let i = 0; i < text.length; i++) view.setUint16(i * 2, text.charCodeAt(i), true);
|
|
153
|
+
return out;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const passwordCreds = derSequence(
|
|
157
|
+
derContext(0, derOctetString(utf16(domain))),
|
|
158
|
+
derContext(1, derOctetString(utf16(username))),
|
|
159
|
+
derContext(2, derOctetString(utf16(password)))
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
return derSequence(
|
|
163
|
+
derContext(0, derInteger(1)), // credType: password
|
|
164
|
+
derContext(1, derOctetString(passwordCreds))
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export class CredSspClient {
|
|
169
|
+
private readonly username: string;
|
|
170
|
+
private readonly domain: string;
|
|
171
|
+
private readonly password: string;
|
|
172
|
+
private readonly serverPublicKey: Uint8Array;
|
|
173
|
+
private readonly send: (bytes: Uint8Array) => void;
|
|
174
|
+
private readonly log: (message: string, detail?: unknown) => void;
|
|
175
|
+
private inbound: Uint8Array;
|
|
176
|
+
private waiters: Array<{ resolve: (bytes: Uint8Array) => void; reject: (error: Error) => void }>;
|
|
177
|
+
|
|
178
|
+
constructor({ username, domain, password, serverPublicKey, send, log }) {
|
|
179
|
+
this.username = username || '';
|
|
180
|
+
this.domain = domain || '';
|
|
181
|
+
this.password = password || '';
|
|
182
|
+
this.serverPublicKey = serverPublicKey;
|
|
183
|
+
this.send = send;
|
|
184
|
+
this.log = log || (() => {});
|
|
185
|
+
|
|
186
|
+
this.inbound = new Uint8Array(0);
|
|
187
|
+
this.waiters = [];
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Feed decrypted TLS application data. */
|
|
191
|
+
receive(bytes: Uint8Array) {
|
|
192
|
+
this.inbound = this.inbound.length === 0 ? bytes : concatBytes(this.inbound, bytes);
|
|
193
|
+
|
|
194
|
+
// TSRequests are self-delimiting, so several can share one TLS record.
|
|
195
|
+
for (;;) {
|
|
196
|
+
const length = derElementLength(this.inbound);
|
|
197
|
+
if (length === 0) return;
|
|
198
|
+
|
|
199
|
+
const request = decodeTsRequest(this.inbound.subarray(0, length));
|
|
200
|
+
this.inbound = this.inbound.subarray(length).slice();
|
|
201
|
+
|
|
202
|
+
const waiter = this.waiters.shift();
|
|
203
|
+
if (waiter) waiter.resolve(request);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#next(timeoutMs = 15000) {
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
const timer = setTimeout(
|
|
210
|
+
() => reject(new Error('CredSSP: the host did not answer within 15s')),
|
|
211
|
+
timeoutMs
|
|
212
|
+
);
|
|
213
|
+
this.waiters.push({
|
|
214
|
+
resolve: (value) => {
|
|
215
|
+
clearTimeout(timer);
|
|
216
|
+
resolve(value);
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async #bindingHash(magic, nonce) {
|
|
223
|
+
const digest = await crypto.subtle.digest(
|
|
224
|
+
'SHA-256',
|
|
225
|
+
concatBytes(new TextEncoder().encode(magic), nonce, this.serverPublicKey)
|
|
226
|
+
);
|
|
227
|
+
return new Uint8Array(digest);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async authenticate() {
|
|
231
|
+
const negotiate = negotiateMessage();
|
|
232
|
+
this.send(encodeTsRequest({ version: CREDSSP_VERSION, negoToken: negotiate }));
|
|
233
|
+
this.log('CredSSP', 'sent NTLM NEGOTIATE');
|
|
234
|
+
|
|
235
|
+
const challengeRequest = await this.#next();
|
|
236
|
+
if (challengeRequest.errorCode) throw new Error(this.#statusMessage(challengeRequest.errorCode));
|
|
237
|
+
if (!challengeRequest.negoToken) throw new Error('CredSSP: the host sent no NTLM challenge');
|
|
238
|
+
|
|
239
|
+
const challengeBytes = challengeRequest.negoToken.slice();
|
|
240
|
+
const challenge = parseChallengeMessage(challengeBytes);
|
|
241
|
+
const targetName = new TextDecoder('utf-16le').decode(challenge.targetName);
|
|
242
|
+
this.log('CredSSP', `received NTLM CHALLENGE from "${targetName}" (host CredSSP version ${challengeRequest.version})`);
|
|
243
|
+
|
|
244
|
+
if (!this.username) {
|
|
245
|
+
return {
|
|
246
|
+
authenticated: false,
|
|
247
|
+
reachedChallenge: true,
|
|
248
|
+
hostVersion: challengeRequest.version,
|
|
249
|
+
targetName,
|
|
250
|
+
note: 'no credentials supplied, stopping after the challenge',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const { message, keys } = authenticateMessage({
|
|
255
|
+
username: this.username,
|
|
256
|
+
domain: this.domain,
|
|
257
|
+
password: this.password,
|
|
258
|
+
challenge,
|
|
259
|
+
negotiateBytes: negotiate,
|
|
260
|
+
challengeBytes,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const sealer = new NtlmSealer(keys);
|
|
264
|
+
const clientNonce = new Uint8Array(32);
|
|
265
|
+
crypto.getRandomValues(clientNonce);
|
|
266
|
+
|
|
267
|
+
// Hosts older than CredSSP 5 expect the bare public key instead of a hash.
|
|
268
|
+
const legacy = challengeRequest.version < 5;
|
|
269
|
+
const binding = legacy
|
|
270
|
+
? this.serverPublicKey
|
|
271
|
+
: await this.#bindingHash(CLIENT_TO_SERVER_MAGIC, clientNonce);
|
|
272
|
+
|
|
273
|
+
this.send(encodeTsRequest({
|
|
274
|
+
version: CREDSSP_VERSION,
|
|
275
|
+
negoToken: message,
|
|
276
|
+
pubKeyAuth: sealer.seal(binding),
|
|
277
|
+
clientNonce: legacy ? null : clientNonce,
|
|
278
|
+
}));
|
|
279
|
+
this.log('CredSSP', `sent NTLM AUTHENTICATE with the ${legacy ? 'legacy' : 'hashed'} public-key binding`);
|
|
280
|
+
|
|
281
|
+
const response = await this.#next();
|
|
282
|
+
if (response.errorCode) throw new Error(this.#statusMessage(response.errorCode));
|
|
283
|
+
if (!response.pubKeyAuth) throw new Error('CredSSP: the host returned no public-key confirmation');
|
|
284
|
+
|
|
285
|
+
const confirmed = sealer.unseal(response.pubKeyAuth);
|
|
286
|
+
const expected = legacy
|
|
287
|
+
? this.#incrementedPublicKey()
|
|
288
|
+
: await this.#bindingHash(SERVER_TO_CLIENT_MAGIC, clientNonce);
|
|
289
|
+
|
|
290
|
+
if (confirmed.length !== expected.length || !expected.every((byte, i) => byte === confirmed[i])) {
|
|
291
|
+
throw new Error(
|
|
292
|
+
'CredSSP: the host public-key confirmation did not match. The TLS session is not the one the host authenticated, which is what this check exists to catch.'
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
this.log('CredSSP', 'host confirmed the public-key binding');
|
|
296
|
+
|
|
297
|
+
this.send(encodeTsRequest({
|
|
298
|
+
version: CREDSSP_VERSION,
|
|
299
|
+
authInfo: sealer.seal(encodeCredentials(this.domain, this.username, this.password)),
|
|
300
|
+
}));
|
|
301
|
+
this.log('CredSSP', 'sent credentials');
|
|
302
|
+
|
|
303
|
+
return { authenticated: true, hostVersion: challengeRequest.version, targetName };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Legacy confirmation is the public key with its first byte incremented. */
|
|
307
|
+
#incrementedPublicKey() {
|
|
308
|
+
const expected = this.serverPublicKey.slice();
|
|
309
|
+
expected[0] = (expected[0] + 1) & 0xff;
|
|
310
|
+
return expected;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#statusMessage(errorCode) {
|
|
314
|
+
return (
|
|
315
|
+
NT_STATUS_MESSAGES[errorCode] ||
|
|
316
|
+
`CredSSP: the host reported error 0x${errorCode.toString(16).padStart(8, '0')}.`
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|