Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/code/browser/workbench/workbench.ts
5237 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
// eslint-disable-next-line no-restricted-syntax
227
const authSessionElement = mainWindow.document.getElementById('vscode-workbench-auth-session');
228
const authSessionElementAttribute = authSessionElement ? authSessionElement.getAttribute('data-settings') : undefined;
229
if (authSessionElementAttribute) {
230
try {
231
authSessionInfo = JSON.parse(authSessionElementAttribute);
232
} catch (error) { /* Invalid session is passed. Ignore. */ }
233
}
234
235
if (!authSessionInfo) {
236
return {};
237
}
238
239
const record: Record<string, string> = {};
240
241
// Settings Sync Entry
242
record[`${product.urlProtocol}.loginAccount`] = JSON.stringify(authSessionInfo);
243
244
// Auth extension Entry
245
if (authSessionInfo.providerId !== 'github') {
246
console.error(`Unexpected auth provider: ${authSessionInfo.providerId}. Expected 'github'.`);
247
return record;
248
}
249
250
const authAccount = JSON.stringify({ extensionId: 'vscode.github-authentication', key: 'github.auth' });
251
record[authAccount] = JSON.stringify(authSessionInfo.scopes.map(scopes => ({
252
id: authSessionInfo.id,
253
scopes,
254
accessToken: authSessionInfo.accessToken
255
})));
256
257
return record;
258
}
259
260
async get(key: string): Promise<string | undefined> {
261
const secrets = await this.secretsPromise;
262
263
return secrets[key];
264
}
265
266
async set(key: string, value: string): Promise<void> {
267
const secrets = await this.secretsPromise;
268
secrets[key] = value;
269
this.secretsPromise = Promise.resolve(secrets);
270
this.save();
271
}
272
273
async delete(key: string): Promise<void> {
274
const secrets = await this.secretsPromise;
275
delete secrets[key];
276
this.secretsPromise = Promise.resolve(secrets);
277
this.save();
278
}
279
280
async keys(): Promise<string[]> {
281
const secrets = await this.secretsPromise;
282
return Object.keys(secrets) || [];
283
}
284
285
private async save(): Promise<void> {
286
try {
287
const encrypted = await this.crypto.seal(JSON.stringify(await this.secretsPromise));
288
localStorage.setItem(this.storageKey, encrypted);
289
} catch (err) {
290
console.error(err);
291
}
292
}
293
}
294
295
class LocalStorageURLCallbackProvider extends Disposable implements IURLCallbackProvider {
296
297
private static REQUEST_ID = 0;
298
299
private static QUERY_KEYS: ('scheme' | 'authority' | 'path' | 'query' | 'fragment')[] = [
300
'scheme',
301
'authority',
302
'path',
303
'query',
304
'fragment'
305
];
306
307
private readonly _onCallback = this._register(new Emitter<URI>());
308
readonly onCallback = this._onCallback.event;
309
310
private pendingCallbacks = new Set<number>();
311
private lastTimeChecked = Date.now();
312
private checkCallbacksTimeout: Timeout | undefined = undefined;
313
private onDidChangeLocalStorageDisposable: IDisposable | undefined;
314
315
constructor(private readonly _callbackRoute: string) {
316
super();
317
}
318
319
create(options: Partial<UriComponents> = {}): URI {
320
const id = ++LocalStorageURLCallbackProvider.REQUEST_ID;
321
const queryParams: string[] = [`vscode-reqid=${id}`];
322
323
for (const key of LocalStorageURLCallbackProvider.QUERY_KEYS) {
324
const value = options[key];
325
326
if (value) {
327
queryParams.push(`vscode-${key}=${encodeURIComponent(value)}`);
328
}
329
}
330
331
// TODO@joao remove eventually
332
// https://github.com/microsoft/vscode-dev/issues/62
333
// https://github.com/microsoft/vscode/blob/159479eb5ae451a66b5dac3c12d564f32f454796/extensions/github-authentication/src/githubServer.ts#L50-L50
334
if (!(options.authority === 'vscode.github-authentication' && options.path === '/dummy')) {
335
const key = `vscode-web.url-callbacks[${id}]`;
336
localStorage.removeItem(key);
337
338
this.pendingCallbacks.add(id);
339
this.startListening();
340
}
341
342
return URI.parse(mainWindow.location.href).with({ path: this._callbackRoute, query: queryParams.join('&') });
343
}
344
345
private startListening(): void {
346
if (this.onDidChangeLocalStorageDisposable) {
347
return;
348
}
349
350
this.onDidChangeLocalStorageDisposable = addDisposableListener(mainWindow, 'storage', () => this.onDidChangeLocalStorage());
351
}
352
353
private stopListening(): void {
354
this.onDidChangeLocalStorageDisposable?.dispose();
355
this.onDidChangeLocalStorageDisposable = undefined;
356
}
357
358
// this fires every time local storage changes, but we
359
// don't want to check more often than once a second
360
private async onDidChangeLocalStorage(): Promise<void> {
361
const ellapsed = Date.now() - this.lastTimeChecked;
362
363
if (ellapsed > 1000) {
364
this.checkCallbacks();
365
} else if (this.checkCallbacksTimeout === undefined) {
366
this.checkCallbacksTimeout = setTimeout(() => {
367
this.checkCallbacksTimeout = undefined;
368
this.checkCallbacks();
369
}, 1000 - ellapsed);
370
}
371
}
372
373
private checkCallbacks(): void {
374
let pendingCallbacks: Set<number> | undefined;
375
376
for (const id of this.pendingCallbacks) {
377
const key = `vscode-web.url-callbacks[${id}]`;
378
const result = localStorage.getItem(key);
379
380
if (result !== null) {
381
try {
382
this._onCallback.fire(URI.revive(JSON.parse(result)));
383
} catch (error) {
384
console.error(error);
385
}
386
387
pendingCallbacks = pendingCallbacks ?? new Set(this.pendingCallbacks);
388
pendingCallbacks.delete(id);
389
localStorage.removeItem(key);
390
}
391
}
392
393
if (pendingCallbacks) {
394
this.pendingCallbacks = pendingCallbacks;
395
396
if (this.pendingCallbacks.size === 0) {
397
this.stopListening();
398
}
399
}
400
401
this.lastTimeChecked = Date.now();
402
}
403
}
404
405
class WorkspaceProvider implements IWorkspaceProvider {
406
407
private static QUERY_PARAM_EMPTY_WINDOW = 'ew';
408
private static QUERY_PARAM_FOLDER = 'folder';
409
private static QUERY_PARAM_WORKSPACE = 'workspace';
410
411
private static QUERY_PARAM_PAYLOAD = 'payload';
412
413
static create(config: IWorkbenchConstructionOptions & { folderUri?: UriComponents; workspaceUri?: UriComponents }) {
414
let foundWorkspace = false;
415
let workspace: IWorkspace;
416
let payload = Object.create(null);
417
418
const query = new URL(document.location.href).searchParams;
419
query.forEach((value, key) => {
420
switch (key) {
421
422
// Folder
423
case WorkspaceProvider.QUERY_PARAM_FOLDER:
424
if (config.remoteAuthority && value.startsWith(posix.sep)) {
425
// when connected to a remote and having a value
426
// that is a path (begins with a `/`), assume this
427
// is a vscode-remote resource as simplified URL.
428
workspace = { folderUri: URI.from({ scheme: Schemas.vscodeRemote, path: value, authority: config.remoteAuthority }) };
429
} else {
430
workspace = { folderUri: URI.parse(value) };
431
}
432
foundWorkspace = true;
433
break;
434
435
// Workspace
436
case WorkspaceProvider.QUERY_PARAM_WORKSPACE:
437
if (config.remoteAuthority && value.startsWith(posix.sep)) {
438
// when connected to a remote and having a value
439
// that is a path (begins with a `/`), assume this
440
// is a vscode-remote resource as simplified URL.
441
workspace = { workspaceUri: URI.from({ scheme: Schemas.vscodeRemote, path: value, authority: config.remoteAuthority }) };
442
} else {
443
workspace = { workspaceUri: URI.parse(value) };
444
}
445
foundWorkspace = true;
446
break;
447
448
// Empty
449
case WorkspaceProvider.QUERY_PARAM_EMPTY_WINDOW:
450
workspace = undefined;
451
foundWorkspace = true;
452
break;
453
454
// Payload
455
case WorkspaceProvider.QUERY_PARAM_PAYLOAD:
456
try {
457
payload = parse(value); // use marshalling#parse() to revive potential URIs
458
} catch (error) {
459
console.error(error); // possible invalid JSON
460
}
461
break;
462
}
463
});
464
465
// If no workspace is provided through the URL, check for config
466
// attribute from server
467
if (!foundWorkspace) {
468
if (config.folderUri) {
469
workspace = { folderUri: URI.revive(config.folderUri) };
470
} else if (config.workspaceUri) {
471
workspace = { workspaceUri: URI.revive(config.workspaceUri) };
472
}
473
}
474
475
return new WorkspaceProvider(workspace, payload, config);
476
}
477
478
readonly trusted = true;
479
480
private constructor(
481
readonly workspace: IWorkspace,
482
readonly payload: object,
483
private readonly config: IWorkbenchConstructionOptions
484
) {
485
}
486
487
async open(workspace: IWorkspace, options?: { reuse?: boolean; payload?: object }): Promise<boolean> {
488
if (options?.reuse && !options.payload && this.isSame(this.workspace, workspace)) {
489
return true; // return early if workspace and environment is not changing and we are reusing window
490
}
491
492
const targetHref = this.createTargetUrl(workspace, options);
493
if (targetHref) {
494
if (options?.reuse) {
495
mainWindow.location.href = targetHref;
496
return true;
497
} else {
498
let result;
499
if (isStandalone()) {
500
result = mainWindow.open(targetHref, '_blank', 'toolbar=no'); // ensures to open another 'standalone' window!
501
} else {
502
result = mainWindow.open(targetHref);
503
}
504
505
return !!result;
506
}
507
}
508
509
return false;
510
}
511
512
private createTargetUrl(workspace: IWorkspace, options?: { reuse?: boolean; payload?: object }): string | undefined {
513
514
// Empty
515
let targetHref: string | undefined = undefined;
516
if (!workspace) {
517
targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_EMPTY_WINDOW}=true`;
518
}
519
520
// Folder
521
else if (isFolderToOpen(workspace)) {
522
const queryParamFolder = this.encodeWorkspacePath(workspace.folderUri);
523
targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_FOLDER}=${queryParamFolder}`;
524
}
525
526
// Workspace
527
else if (isWorkspaceToOpen(workspace)) {
528
const queryParamWorkspace = this.encodeWorkspacePath(workspace.workspaceUri);
529
targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_WORKSPACE}=${queryParamWorkspace}`;
530
}
531
532
// Append payload if any
533
if (options?.payload) {
534
targetHref += `&${WorkspaceProvider.QUERY_PARAM_PAYLOAD}=${encodeURIComponent(JSON.stringify(options.payload))}`;
535
}
536
537
return targetHref;
538
}
539
540
private encodeWorkspacePath(uri: URI): string {
541
if (this.config.remoteAuthority && uri.scheme === Schemas.vscodeRemote) {
542
543
// when connected to a remote and having a folder
544
// or workspace for that remote, only use the path
545
// as query value to form shorter, nicer URLs.
546
// however, we still need to `encodeURIComponent`
547
// to ensure to preserve special characters, such
548
// as `+` in the path.
549
550
return encodeURIComponent(`${posix.sep}${ltrim(uri.path, posix.sep)}`).replaceAll('%2F', '/');
551
}
552
553
return encodeURIComponent(uri.toString(true));
554
}
555
556
private isSame(workspaceA: IWorkspace, workspaceB: IWorkspace): boolean {
557
if (!workspaceA || !workspaceB) {
558
return workspaceA === workspaceB; // both empty
559
}
560
561
if (isFolderToOpen(workspaceA) && isFolderToOpen(workspaceB)) {
562
return isEqual(workspaceA.folderUri, workspaceB.folderUri); // same workspace
563
}
564
565
if (isWorkspaceToOpen(workspaceA) && isWorkspaceToOpen(workspaceB)) {
566
return isEqual(workspaceA.workspaceUri, workspaceB.workspaceUri); // same workspace
567
}
568
569
return false;
570
}
571
572
hasRemote(): boolean {
573
if (this.workspace) {
574
if (isFolderToOpen(this.workspace)) {
575
return this.workspace.folderUri.scheme === Schemas.vscodeRemote;
576
}
577
578
if (isWorkspaceToOpen(this.workspace)) {
579
return this.workspace.workspaceUri.scheme === Schemas.vscodeRemote;
580
}
581
}
582
583
return true;
584
}
585
}
586
587
function readCookie(name: string): string | undefined {
588
const cookies = document.cookie.split('; ');
589
for (const cookie of cookies) {
590
if (cookie.startsWith(name + '=')) {
591
return cookie.substring(name.length + 1);
592
}
593
}
594
595
return undefined;
596
}
597
598
(function () {
599
600
// Find config by checking for DOM
601
// eslint-disable-next-line no-restricted-syntax
602
const configElement = mainWindow.document.getElementById('vscode-workbench-web-configuration');
603
const configElementAttribute = configElement ? configElement.getAttribute('data-settings') : undefined;
604
if (!configElement || !configElementAttribute) {
605
throw new Error('Missing web configuration element');
606
}
607
const config: IWorkbenchConstructionOptions & { folderUri?: UriComponents; workspaceUri?: UriComponents; callbackRoute: string } = JSON.parse(configElementAttribute);
608
const secretStorageKeyPath = readCookie('vscode-secret-key-path');
609
const secretStorageCrypto = secretStorageKeyPath && ServerKeyedAESCrypto.supported()
610
? new ServerKeyedAESCrypto(secretStorageKeyPath) : new TransparentCrypto();
611
612
// Create workbench
613
create(mainWindow.document.body, {
614
...config,
615
windowIndicator: config.windowIndicator ?? { label: '$(remote)', tooltip: `${product.nameShort} Web` },
616
settingsSyncOptions: config.settingsSyncOptions ? { enabled: config.settingsSyncOptions.enabled, } : undefined,
617
workspaceProvider: WorkspaceProvider.create(config),
618
urlCallbackProvider: new LocalStorageURLCallbackProvider(config.callbackRoute),
619
secretStorageProvider: config.remoteAuthority && !secretStorageKeyPath
620
? undefined /* with a remote without embedder-preferred storage, store on the remote */
621
: new LocalStorageSecretStorageProvider(secretStorageCrypto),
622
});
623
})();
624
625