Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/code/browser/workbench/workbench.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { isStandalone } from '../../../base/browser/browser.js';
7
import { addDisposableListener } from '../../../base/browser/dom.js';
8
import { mainWindow } from '../../../base/browser/window.js';
9
import { VSBuffer, decodeBase64, encodeBase64 } from '../../../base/common/buffer.js';
10
import { Emitter } from '../../../base/common/event.js';
11
import { Disposable, IDisposable } from '../../../base/common/lifecycle.js';
12
import { parse } from '../../../base/common/marshalling.js';
13
import { Schemas } from '../../../base/common/network.js';
14
import { posix } from '../../../base/common/path.js';
15
import { isEqual } from '../../../base/common/resources.js';
16
import { ltrim } from '../../../base/common/strings.js';
17
import { URI, UriComponents } from '../../../base/common/uri.js';
18
import product from '../../../platform/product/common/product.js';
19
import { ISecretStorageProvider } from '../../../platform/secrets/common/secrets.js';
20
import { isFolderToOpen, isWorkspaceToOpen } from '../../../platform/window/common/window.js';
21
import type { IWorkbenchConstructionOptions, IWorkspace, IWorkspaceProvider } from '../../../workbench/browser/web.api.js';
22
import { AuthenticationSessionInfo } from '../../../workbench/services/authentication/browser/authenticationService.js';
23
import type { IURLCallbackProvider } from '../../../workbench/services/url/browser/urlService.js';
24
import { create } from '../../../workbench/workbench.web.main.internal.js';
25
26
interface ISecretStorageCrypto {
27
seal(data: string): Promise<string>;
28
unseal(data: string): Promise<string>;
29
}
30
31
class TransparentCrypto implements ISecretStorageCrypto {
32
33
async seal(data: string): Promise<string> {
34
return data;
35
}
36
37
async unseal(data: string): Promise<string> {
38
return data;
39
}
40
}
41
42
const enum AESConstants {
43
ALGORITHM = 'AES-GCM',
44
KEY_LENGTH = 256,
45
IV_LENGTH = 12,
46
}
47
48
class NetworkError extends Error {
49
50
constructor(inner: Error) {
51
super(inner.message);
52
this.name = inner.name;
53
this.stack = inner.stack;
54
}
55
}
56
57
class ServerKeyedAESCrypto implements ISecretStorageCrypto {
58
59
private serverKey: Uint8Array | undefined;
60
61
/**
62
* Gets whether the algorithm is supported; requires a secure context
63
*/
64
static supported() {
65
return !!crypto.subtle;
66
}
67
68
constructor(private readonly authEndpoint: string) { }
69
70
async seal(data: string): Promise<string> {
71
// Get a new key and IV on every change, to avoid the risk of reusing the same key and IV pair with AES-GCM
72
// (see also: https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams#properties)
73
const iv = mainWindow.crypto.getRandomValues(new Uint8Array(AESConstants.IV_LENGTH));
74
// crypto.getRandomValues isn't a good-enough PRNG to generate crypto keys, so we need to use crypto.subtle.generateKey and export the key instead
75
const clientKeyObj = await mainWindow.crypto.subtle.generateKey(
76
{ name: AESConstants.ALGORITHM as const, length: AESConstants.KEY_LENGTH as const },
77
true,
78
['encrypt', 'decrypt']
79
);
80
81
const clientKey = new Uint8Array(await mainWindow.crypto.subtle.exportKey('raw', clientKeyObj));
82
const key = await this.getKey(clientKey);
83
const dataUint8Array = new TextEncoder().encode(data);
84
const cipherText: ArrayBuffer = await mainWindow.crypto.subtle.encrypt(
85
{ name: AESConstants.ALGORITHM as const, iv },
86
key,
87
dataUint8Array
88
);
89
90
// Base64 encode the result and store the ciphertext, the key, and the IV in localStorage
91
// Note that the clientKey and IV don't need to be secret
92
const result = new Uint8Array([...clientKey, ...iv, ...new Uint8Array(cipherText)]);
93
return encodeBase64(VSBuffer.wrap(result));
94
}
95
96
async unseal(data: string): Promise<string> {
97
// encrypted should contain, in order: the key (32-byte), the IV for AES-GCM (12-byte) and the ciphertext (which has the GCM auth tag at the end)
98
// Minimum length must be 44 (key+IV length) + 16 bytes (1 block encrypted with AES - regardless of key size)
99
const dataUint8Array = decodeBase64(data);
100
101
if (dataUint8Array.byteLength < 60) {
102
throw Error('Invalid length for the value for credentials.crypto');
103
}
104
105
const keyLength = AESConstants.KEY_LENGTH / 8;
106
const clientKey = dataUint8Array.slice(0, keyLength);
107
const iv = dataUint8Array.slice(keyLength, keyLength + AESConstants.IV_LENGTH);
108
const cipherText = dataUint8Array.slice(keyLength + AESConstants.IV_LENGTH);
109
110
// Do the decryption and parse the result as JSON
111
const key = await this.getKey(clientKey.buffer);
112
const decrypted = await mainWindow.crypto.subtle.decrypt(
113
{ name: AESConstants.ALGORITHM as const, iv: iv.buffer as Uint8Array<ArrayBuffer> },
114
key,
115
cipherText.buffer as Uint8Array<ArrayBuffer>
116
);
117
118
return new TextDecoder().decode(new Uint8Array(decrypted));
119
}
120
121
/**
122
* Given a clientKey, returns the CryptoKey object that is used to encrypt/decrypt the data.
123
* The actual key is (clientKey XOR serverKey)
124
*/
125
private async getKey(clientKey: Uint8Array): Promise<CryptoKey> {
126
if (!clientKey || clientKey.byteLength !== AESConstants.KEY_LENGTH / 8) {
127
throw Error('Invalid length for clientKey');
128
}
129
130
const serverKey = await this.getServerKeyPart();
131
const keyData = new Uint8Array(AESConstants.KEY_LENGTH / 8);
132
133
for (let i = 0; i < keyData.byteLength; i++) {
134
keyData[i] = clientKey[i]! ^ serverKey[i]!;
135
}
136
137
return mainWindow.crypto.subtle.importKey(
138
'raw',
139
keyData,
140
{
141
name: AESConstants.ALGORITHM as const,
142
length: AESConstants.KEY_LENGTH as const,
143
},
144
true,
145
['encrypt', 'decrypt']
146
);
147
}
148
149
private async getServerKeyPart(): Promise<Uint8Array> {
150
if (this.serverKey) {
151
return this.serverKey;
152
}
153
154
let attempt = 0;
155
let lastError: Error | undefined;
156
157
while (attempt <= 3) {
158
try {
159
const res = await fetch(this.authEndpoint, { credentials: 'include', method: 'POST' });
160
if (!res.ok) {
161
throw new Error(res.statusText);
162
}
163
164
const serverKey = new Uint8Array(await res.arrayBuffer());
165
if (serverKey.byteLength !== AESConstants.KEY_LENGTH / 8) {
166
throw Error(`The key retrieved by the server is not ${AESConstants.KEY_LENGTH} bit long.`);
167
}
168
169
this.serverKey = serverKey;
170
171
return this.serverKey;
172
} catch (e) {
173
lastError = e instanceof Error ? e : new Error(String(e));
174
attempt++;
175
176
// exponential backoff
177
await new Promise(resolve => setTimeout(resolve, attempt * attempt * 100));
178
}
179
}
180
181
if (lastError) {
182
throw new NetworkError(lastError);
183
}
184
185
throw new Error('Unknown error');
186
}
187
}
188
189
export class LocalStorageSecretStorageProvider implements ISecretStorageProvider {
190
191
private readonly storageKey = 'secrets.provider';
192
193
private secretsPromise: Promise<Record<string, string>>;
194
195
type: 'in-memory' | 'persisted' | 'unknown' = 'persisted';
196
197
constructor(
198
private readonly crypto: ISecretStorageCrypto,
199
) {
200
this.secretsPromise = this.load();
201
}
202
203
private async load(): Promise<Record<string, string>> {
204
const record = this.loadAuthSessionFromElement();
205
206
const encrypted = localStorage.getItem(this.storageKey);
207
if (encrypted) {
208
try {
209
const decrypted = JSON.parse(await this.crypto.unseal(encrypted));
210
211
return { ...record, ...decrypted };
212
} catch (err) {
213
// TODO: send telemetry
214
console.error('Failed to decrypt secrets from localStorage', err);
215
if (!(err instanceof NetworkError)) {
216
localStorage.removeItem(this.storageKey);
217
}
218
}
219
}
220
221
return record;
222
}
223
224
private loadAuthSessionFromElement(): Record<string, string> {
225
let authSessionInfo: (AuthenticationSessionInfo & { scopes: string[][] }) | undefined;
226
const authSessionElement = mainWindow.document.getElementById('vscode-workbench-auth-session');
227
const authSessionElementAttribute = authSessionElement ? authSessionElement.getAttribute('data-settings') : undefined;
228
if (authSessionElementAttribute) {
229
try {
230
authSessionInfo = JSON.parse(authSessionElementAttribute);
231
} catch (error) { /* Invalid session is passed. Ignore. */ }
232
}
233
234
if (!authSessionInfo) {
235
return {};
236
}
237
238
const record: Record<string, string> = {};
239
240
// Settings Sync Entry
241
record[`${product.urlProtocol}.loginAccount`] = JSON.stringify(authSessionInfo);
242
243
// Auth extension Entry
244
if (authSessionInfo.providerId !== 'github') {
245
console.error(`Unexpected auth provider: ${authSessionInfo.providerId}. Expected 'github'.`);
246
return record;
247
}
248
249
const authAccount = JSON.stringify({ extensionId: 'vscode.github-authentication', key: 'github.auth' });
250
record[authAccount] = JSON.stringify(authSessionInfo.scopes.map(scopes => ({
251
id: authSessionInfo.id,
252
scopes,
253
accessToken: authSessionInfo.accessToken
254
})));
255
256
return record;
257
}
258
259
async get(key: string): Promise<string | undefined> {
260
const secrets = await this.secretsPromise;
261
262
return secrets[key];
263
}
264
265
async set(key: string, value: string): Promise<void> {
266
const secrets = await this.secretsPromise;
267
secrets[key] = value;
268
this.secretsPromise = Promise.resolve(secrets);
269
this.save();
270
}
271
272
async delete(key: string): Promise<void> {
273
const secrets = await this.secretsPromise;
274
delete secrets[key];
275
this.secretsPromise = Promise.resolve(secrets);
276
this.save();
277
}
278
279
async keys(): Promise<string[]> {
280
const secrets = await this.secretsPromise;
281
return Object.keys(secrets) || [];
282
}
283
284
private async save(): Promise<void> {
285
try {
286
const encrypted = await this.crypto.seal(JSON.stringify(await this.secretsPromise));
287
localStorage.setItem(this.storageKey, encrypted);
288
} catch (err) {
289
console.error(err);
290
}
291
}
292
}
293
294
class LocalStorageURLCallbackProvider extends Disposable implements IURLCallbackProvider {
295
296
private static REQUEST_ID = 0;
297
298
private static QUERY_KEYS: ('scheme' | 'authority' | 'path' | 'query' | 'fragment')[] = [
299
'scheme',
300
'authority',
301
'path',
302
'query',
303
'fragment'
304
];
305
306
private readonly _onCallback = this._register(new Emitter<URI>());
307
readonly onCallback = this._onCallback.event;
308
309
private pendingCallbacks = new Set<number>();
310
private lastTimeChecked = Date.now();
311
private checkCallbacksTimeout: Timeout | undefined = undefined;
312
private onDidChangeLocalStorageDisposable: IDisposable | undefined;
313
314
constructor(private readonly _callbackRoute: string) {
315
super();
316
}
317
318
create(options: Partial<UriComponents> = {}): URI {
319
const id = ++LocalStorageURLCallbackProvider.REQUEST_ID;
320
const queryParams: string[] = [`vscode-reqid=${id}`];
321
322
for (const key of LocalStorageURLCallbackProvider.QUERY_KEYS) {
323
const value = options[key];
324
325
if (value) {
326
queryParams.push(`vscode-${key}=${encodeURIComponent(value)}`);
327
}
328
}
329
330
// TODO@joao remove eventually
331
// https://github.com/microsoft/vscode-dev/issues/62
332
// https://github.com/microsoft/vscode/blob/159479eb5ae451a66b5dac3c12d564f32f454796/extensions/github-authentication/src/githubServer.ts#L50-L50
333
if (!(options.authority === 'vscode.github-authentication' && options.path === '/dummy')) {
334
const key = `vscode-web.url-callbacks[${id}]`;
335
localStorage.removeItem(key);
336
337
this.pendingCallbacks.add(id);
338
this.startListening();
339
}
340
341
return URI.parse(mainWindow.location.href).with({ path: this._callbackRoute, query: queryParams.join('&') });
342
}
343
344
private startListening(): void {
345
if (this.onDidChangeLocalStorageDisposable) {
346
return;
347
}
348
349
this.onDidChangeLocalStorageDisposable = addDisposableListener(mainWindow, 'storage', () => this.onDidChangeLocalStorage());
350
}
351
352
private stopListening(): void {
353
this.onDidChangeLocalStorageDisposable?.dispose();
354
this.onDidChangeLocalStorageDisposable = undefined;
355
}
356
357
// this fires every time local storage changes, but we
358
// don't want to check more often than once a second
359
private async onDidChangeLocalStorage(): Promise<void> {
360
const ellapsed = Date.now() - this.lastTimeChecked;
361
362
if (ellapsed > 1000) {
363
this.checkCallbacks();
364
} else if (this.checkCallbacksTimeout === undefined) {
365
this.checkCallbacksTimeout = setTimeout(() => {
366
this.checkCallbacksTimeout = undefined;
367
this.checkCallbacks();
368
}, 1000 - ellapsed);
369
}
370
}
371
372
private checkCallbacks(): void {
373
let pendingCallbacks: Set<number> | undefined;
374
375
for (const id of this.pendingCallbacks) {
376
const key = `vscode-web.url-callbacks[${id}]`;
377
const result = localStorage.getItem(key);
378
379
if (result !== null) {
380
try {
381
this._onCallback.fire(URI.revive(JSON.parse(result)));
382
} catch (error) {
383
console.error(error);
384
}
385
386
pendingCallbacks = pendingCallbacks ?? new Set(this.pendingCallbacks);
387
pendingCallbacks.delete(id);
388
localStorage.removeItem(key);
389
}
390
}
391
392
if (pendingCallbacks) {
393
this.pendingCallbacks = pendingCallbacks;
394
395
if (this.pendingCallbacks.size === 0) {
396
this.stopListening();
397
}
398
}
399
400
this.lastTimeChecked = Date.now();
401
}
402
}
403
404
class WorkspaceProvider implements IWorkspaceProvider {
405
406
private static QUERY_PARAM_EMPTY_WINDOW = 'ew';
407
private static QUERY_PARAM_FOLDER = 'folder';
408
private static QUERY_PARAM_WORKSPACE = 'workspace';
409
410
private static QUERY_PARAM_PAYLOAD = 'payload';
411
412
static create(config: IWorkbenchConstructionOptions & { folderUri?: UriComponents; workspaceUri?: UriComponents }) {
413
let foundWorkspace = false;
414
let workspace: IWorkspace;
415
let payload = Object.create(null);
416
417
const query = new URL(document.location.href).searchParams;
418
query.forEach((value, key) => {
419
switch (key) {
420
421
// Folder
422
case WorkspaceProvider.QUERY_PARAM_FOLDER:
423
if (config.remoteAuthority && value.startsWith(posix.sep)) {
424
// when connected to a remote and having a value
425
// that is a path (begins with a `/`), assume this
426
// is a vscode-remote resource as simplified URL.
427
workspace = { folderUri: URI.from({ scheme: Schemas.vscodeRemote, path: value, authority: config.remoteAuthority }) };
428
} else {
429
workspace = { folderUri: URI.parse(value) };
430
}
431
foundWorkspace = true;
432
break;
433
434
// Workspace
435
case WorkspaceProvider.QUERY_PARAM_WORKSPACE:
436
if (config.remoteAuthority && value.startsWith(posix.sep)) {
437
// when connected to a remote and having a value
438
// that is a path (begins with a `/`), assume this
439
// is a vscode-remote resource as simplified URL.
440
workspace = { workspaceUri: URI.from({ scheme: Schemas.vscodeRemote, path: value, authority: config.remoteAuthority }) };
441
} else {
442
workspace = { workspaceUri: URI.parse(value) };
443
}
444
foundWorkspace = true;
445
break;
446
447
// Empty
448
case WorkspaceProvider.QUERY_PARAM_EMPTY_WINDOW:
449
workspace = undefined;
450
foundWorkspace = true;
451
break;
452
453
// Payload
454
case WorkspaceProvider.QUERY_PARAM_PAYLOAD:
455
try {
456
payload = parse(value); // use marshalling#parse() to revive potential URIs
457
} catch (error) {
458
console.error(error); // possible invalid JSON
459
}
460
break;
461
}
462
});
463
464
// If no workspace is provided through the URL, check for config
465
// attribute from server
466
if (!foundWorkspace) {
467
if (config.folderUri) {
468
workspace = { folderUri: URI.revive(config.folderUri) };
469
} else if (config.workspaceUri) {
470
workspace = { workspaceUri: URI.revive(config.workspaceUri) };
471
}
472
}
473
474
return new WorkspaceProvider(workspace, payload, config);
475
}
476
477
readonly trusted = true;
478
479
private constructor(
480
readonly workspace: IWorkspace,
481
readonly payload: object,
482
private readonly config: IWorkbenchConstructionOptions
483
) {
484
}
485
486
async open(workspace: IWorkspace, options?: { reuse?: boolean; payload?: object }): Promise<boolean> {
487
if (options?.reuse && !options.payload && this.isSame(this.workspace, workspace)) {
488
return true; // return early if workspace and environment is not changing and we are reusing window
489
}
490
491
const targetHref = this.createTargetUrl(workspace, options);
492
if (targetHref) {
493
if (options?.reuse) {
494
mainWindow.location.href = targetHref;
495
return true;
496
} else {
497
let result;
498
if (isStandalone()) {
499
result = mainWindow.open(targetHref, '_blank', 'toolbar=no'); // ensures to open another 'standalone' window!
500
} else {
501
result = mainWindow.open(targetHref);
502
}
503
504
return !!result;
505
}
506
}
507
508
return false;
509
}
510
511
private createTargetUrl(workspace: IWorkspace, options?: { reuse?: boolean; payload?: object }): string | undefined {
512
513
// Empty
514
let targetHref: string | undefined = undefined;
515
if (!workspace) {
516
targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_EMPTY_WINDOW}=true`;
517
}
518
519
// Folder
520
else if (isFolderToOpen(workspace)) {
521
const queryParamFolder = this.encodeWorkspacePath(workspace.folderUri);
522
targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_FOLDER}=${queryParamFolder}`;
523
}
524
525
// Workspace
526
else if (isWorkspaceToOpen(workspace)) {
527
const queryParamWorkspace = this.encodeWorkspacePath(workspace.workspaceUri);
528
targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_WORKSPACE}=${queryParamWorkspace}`;
529
}
530
531
// Append payload if any
532
if (options?.payload) {
533
targetHref += `&${WorkspaceProvider.QUERY_PARAM_PAYLOAD}=${encodeURIComponent(JSON.stringify(options.payload))}`;
534
}
535
536
return targetHref;
537
}
538
539
private encodeWorkspacePath(uri: URI): string {
540
if (this.config.remoteAuthority && uri.scheme === Schemas.vscodeRemote) {
541
542
// when connected to a remote and having a folder
543
// or workspace for that remote, only use the path
544
// as query value to form shorter, nicer URLs.
545
// however, we still need to `encodeURIComponent`
546
// to ensure to preserve special characters, such
547
// as `+` in the path.
548
549
return encodeURIComponent(`${posix.sep}${ltrim(uri.path, posix.sep)}`).replaceAll('%2F', '/');
550
}
551
552
return encodeURIComponent(uri.toString(true));
553
}
554
555
private isSame(workspaceA: IWorkspace, workspaceB: IWorkspace): boolean {
556
if (!workspaceA || !workspaceB) {
557
return workspaceA === workspaceB; // both empty
558
}
559
560
if (isFolderToOpen(workspaceA) && isFolderToOpen(workspaceB)) {
561
return isEqual(workspaceA.folderUri, workspaceB.folderUri); // same workspace
562
}
563
564
if (isWorkspaceToOpen(workspaceA) && isWorkspaceToOpen(workspaceB)) {
565
return isEqual(workspaceA.workspaceUri, workspaceB.workspaceUri); // same workspace
566
}
567
568
return false;
569
}
570
571
hasRemote(): boolean {
572
if (this.workspace) {
573
if (isFolderToOpen(this.workspace)) {
574
return this.workspace.folderUri.scheme === Schemas.vscodeRemote;
575
}
576
577
if (isWorkspaceToOpen(this.workspace)) {
578
return this.workspace.workspaceUri.scheme === Schemas.vscodeRemote;
579
}
580
}
581
582
return true;
583
}
584
}
585
586
function readCookie(name: string): string | undefined {
587
const cookies = document.cookie.split('; ');
588
for (const cookie of cookies) {
589
if (cookie.startsWith(name + '=')) {
590
return cookie.substring(name.length + 1);
591
}
592
}
593
594
return undefined;
595
}
596
597
(function () {
598
599
// Find config by checking for DOM
600
const configElement = mainWindow.document.getElementById('vscode-workbench-web-configuration');
601
const configElementAttribute = configElement ? configElement.getAttribute('data-settings') : undefined;
602
if (!configElement || !configElementAttribute) {
603
throw new Error('Missing web configuration element');
604
}
605
const config: IWorkbenchConstructionOptions & { folderUri?: UriComponents; workspaceUri?: UriComponents; callbackRoute: string } = JSON.parse(configElementAttribute);
606
const secretStorageKeyPath = readCookie('vscode-secret-key-path');
607
const secretStorageCrypto = secretStorageKeyPath && ServerKeyedAESCrypto.supported()
608
? new ServerKeyedAESCrypto(secretStorageKeyPath) : new TransparentCrypto();
609
610
// Create workbench
611
create(mainWindow.document.body, {
612
...config,
613
windowIndicator: config.windowIndicator ?? { label: '$(remote)', tooltip: `${product.nameShort} Web` },
614
settingsSyncOptions: config.settingsSyncOptions ? { enabled: config.settingsSyncOptions.enabled, } : undefined,
615
workspaceProvider: WorkspaceProvider.create(config),
616
urlCallbackProvider: new LocalStorageURLCallbackProvider(config.callbackRoute),
617
secretStorageProvider: config.remoteAuthority && !secretStorageKeyPath
618
? undefined /* with a remote without embedder-preferred storage, store on the remote */
619
: new LocalStorageSecretStorageProvider(secretStorageCrypto),
620
});
621
})();
622
623