Path: blob/main/src/vs/code/browser/workbench/workbench.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { isStandalone } from '../../../base/browser/browser.js';6import { addDisposableListener } from '../../../base/browser/dom.js';7import { mainWindow } from '../../../base/browser/window.js';8import { VSBuffer, decodeBase64, encodeBase64 } from '../../../base/common/buffer.js';9import { Emitter } from '../../../base/common/event.js';10import { Disposable, IDisposable } from '../../../base/common/lifecycle.js';11import { parse } from '../../../base/common/marshalling.js';12import { Schemas } from '../../../base/common/network.js';13import { posix } from '../../../base/common/path.js';14import { isEqual } from '../../../base/common/resources.js';15import { ltrim } from '../../../base/common/strings.js';16import { URI, UriComponents } from '../../../base/common/uri.js';17import product from '../../../platform/product/common/product.js';18import { ISecretStorageProvider } from '../../../platform/secrets/common/secrets.js';19import { isFolderToOpen, isWorkspaceToOpen } from '../../../platform/window/common/window.js';20import type { IWorkbenchConstructionOptions, IWorkspace, IWorkspaceProvider } from '../../../workbench/browser/web.api.js';21import { AuthenticationSessionInfo } from '../../../workbench/services/authentication/browser/authenticationService.js';22import type { IURLCallbackProvider } from '../../../workbench/services/url/browser/urlService.js';23import { create } from '../../../workbench/workbench.web.main.internal.js';2425interface ISecretStorageCrypto {26seal(data: string): Promise<string>;27unseal(data: string): Promise<string>;28}2930class TransparentCrypto implements ISecretStorageCrypto {3132async seal(data: string): Promise<string> {33return data;34}3536async unseal(data: string): Promise<string> {37return data;38}39}4041const enum AESConstants {42ALGORITHM = 'AES-GCM',43KEY_LENGTH = 256,44IV_LENGTH = 12,45}4647class NetworkError extends Error {4849constructor(inner: Error) {50super(inner.message);51this.name = inner.name;52this.stack = inner.stack;53}54}5556class ServerKeyedAESCrypto implements ISecretStorageCrypto {5758private serverKey: Uint8Array | undefined;5960/**61* Gets whether the algorithm is supported; requires a secure context62*/63static supported() {64return !!crypto.subtle;65}6667constructor(private readonly authEndpoint: string) { }6869async seal(data: string): Promise<string> {70// Get a new key and IV on every change, to avoid the risk of reusing the same key and IV pair with AES-GCM71// (see also: https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams#properties)72const iv = mainWindow.crypto.getRandomValues(new Uint8Array(AESConstants.IV_LENGTH));73// crypto.getRandomValues isn't a good-enough PRNG to generate crypto keys, so we need to use crypto.subtle.generateKey and export the key instead74const clientKeyObj = await mainWindow.crypto.subtle.generateKey(75{ name: AESConstants.ALGORITHM as const, length: AESConstants.KEY_LENGTH as const },76true,77['encrypt', 'decrypt']78);7980const clientKey = new Uint8Array(await mainWindow.crypto.subtle.exportKey('raw', clientKeyObj));81const key = await this.getKey(clientKey);82const dataUint8Array = new TextEncoder().encode(data);83const cipherText: ArrayBuffer = await mainWindow.crypto.subtle.encrypt(84{ name: AESConstants.ALGORITHM as const, iv },85key,86dataUint8Array87);8889// Base64 encode the result and store the ciphertext, the key, and the IV in localStorage90// Note that the clientKey and IV don't need to be secret91const result = new Uint8Array([...clientKey, ...iv, ...new Uint8Array(cipherText)]);92return encodeBase64(VSBuffer.wrap(result));93}9495async unseal(data: string): Promise<string> {96// 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)97// Minimum length must be 44 (key+IV length) + 16 bytes (1 block encrypted with AES - regardless of key size)98const dataUint8Array = decodeBase64(data);99100if (dataUint8Array.byteLength < 60) {101throw Error('Invalid length for the value for credentials.crypto');102}103104const keyLength = AESConstants.KEY_LENGTH / 8;105const clientKey = dataUint8Array.slice(0, keyLength);106const iv = dataUint8Array.slice(keyLength, keyLength + AESConstants.IV_LENGTH);107const cipherText = dataUint8Array.slice(keyLength + AESConstants.IV_LENGTH);108109// Do the decryption and parse the result as JSON110const key = await this.getKey(clientKey.buffer);111const decrypted = await mainWindow.crypto.subtle.decrypt(112{ name: AESConstants.ALGORITHM as const, iv: iv.buffer as Uint8Array<ArrayBuffer> },113key,114cipherText.buffer as Uint8Array<ArrayBuffer>115);116117return new TextDecoder().decode(new Uint8Array(decrypted));118}119120/**121* Given a clientKey, returns the CryptoKey object that is used to encrypt/decrypt the data.122* The actual key is (clientKey XOR serverKey)123*/124private async getKey(clientKey: Uint8Array): Promise<CryptoKey> {125if (!clientKey || clientKey.byteLength !== AESConstants.KEY_LENGTH / 8) {126throw Error('Invalid length for clientKey');127}128129const serverKey = await this.getServerKeyPart();130const keyData = new Uint8Array(AESConstants.KEY_LENGTH / 8);131132for (let i = 0; i < keyData.byteLength; i++) {133keyData[i] = clientKey[i]! ^ serverKey[i]!;134}135136return mainWindow.crypto.subtle.importKey(137'raw',138keyData,139{140name: AESConstants.ALGORITHM as const,141length: AESConstants.KEY_LENGTH as const,142},143true,144['encrypt', 'decrypt']145);146}147148private async getServerKeyPart(): Promise<Uint8Array> {149if (this.serverKey) {150return this.serverKey;151}152153let attempt = 0;154let lastError: Error | undefined;155156while (attempt <= 3) {157try {158const res = await fetch(this.authEndpoint, { credentials: 'include', method: 'POST' });159if (!res.ok) {160throw new Error(res.statusText);161}162163const serverKey = new Uint8Array(await res.arrayBuffer());164if (serverKey.byteLength !== AESConstants.KEY_LENGTH / 8) {165throw Error(`The key retrieved by the server is not ${AESConstants.KEY_LENGTH} bit long.`);166}167168this.serverKey = serverKey;169170return this.serverKey;171} catch (e) {172lastError = e instanceof Error ? e : new Error(String(e));173attempt++;174175// exponential backoff176await new Promise(resolve => setTimeout(resolve, attempt * attempt * 100));177}178}179180if (lastError) {181throw new NetworkError(lastError);182}183184throw new Error('Unknown error');185}186}187188export class LocalStorageSecretStorageProvider implements ISecretStorageProvider {189190private readonly storageKey = 'secrets.provider';191192private secretsPromise: Promise<Record<string, string>>;193194type: 'in-memory' | 'persisted' | 'unknown' = 'persisted';195196constructor(197private readonly crypto: ISecretStorageCrypto,198) {199this.secretsPromise = this.load();200}201202private async load(): Promise<Record<string, string>> {203const record = this.loadAuthSessionFromElement();204205const encrypted = localStorage.getItem(this.storageKey);206if (encrypted) {207try {208const decrypted = JSON.parse(await this.crypto.unseal(encrypted));209210return { ...record, ...decrypted };211} catch (err) {212// TODO: send telemetry213console.error('Failed to decrypt secrets from localStorage', err);214if (!(err instanceof NetworkError)) {215localStorage.removeItem(this.storageKey);216}217}218}219220return record;221}222223private loadAuthSessionFromElement(): Record<string, string> {224let authSessionInfo: (AuthenticationSessionInfo & { scopes: string[][] }) | undefined;225const authSessionElement = mainWindow.document.getElementById('vscode-workbench-auth-session');226const authSessionElementAttribute = authSessionElement ? authSessionElement.getAttribute('data-settings') : undefined;227if (authSessionElementAttribute) {228try {229authSessionInfo = JSON.parse(authSessionElementAttribute);230} catch (error) { /* Invalid session is passed. Ignore. */ }231}232233if (!authSessionInfo) {234return {};235}236237const record: Record<string, string> = {};238239// Settings Sync Entry240record[`${product.urlProtocol}.loginAccount`] = JSON.stringify(authSessionInfo);241242// Auth extension Entry243if (authSessionInfo.providerId !== 'github') {244console.error(`Unexpected auth provider: ${authSessionInfo.providerId}. Expected 'github'.`);245return record;246}247248const authAccount = JSON.stringify({ extensionId: 'vscode.github-authentication', key: 'github.auth' });249record[authAccount] = JSON.stringify(authSessionInfo.scopes.map(scopes => ({250id: authSessionInfo.id,251scopes,252accessToken: authSessionInfo.accessToken253})));254255return record;256}257258async get(key: string): Promise<string | undefined> {259const secrets = await this.secretsPromise;260261return secrets[key];262}263264async set(key: string, value: string): Promise<void> {265const secrets = await this.secretsPromise;266secrets[key] = value;267this.secretsPromise = Promise.resolve(secrets);268this.save();269}270271async delete(key: string): Promise<void> {272const secrets = await this.secretsPromise;273delete secrets[key];274this.secretsPromise = Promise.resolve(secrets);275this.save();276}277278async keys(): Promise<string[]> {279const secrets = await this.secretsPromise;280return Object.keys(secrets) || [];281}282283private async save(): Promise<void> {284try {285const encrypted = await this.crypto.seal(JSON.stringify(await this.secretsPromise));286localStorage.setItem(this.storageKey, encrypted);287} catch (err) {288console.error(err);289}290}291}292293class LocalStorageURLCallbackProvider extends Disposable implements IURLCallbackProvider {294295private static REQUEST_ID = 0;296297private static QUERY_KEYS: ('scheme' | 'authority' | 'path' | 'query' | 'fragment')[] = [298'scheme',299'authority',300'path',301'query',302'fragment'303];304305private readonly _onCallback = this._register(new Emitter<URI>());306readonly onCallback = this._onCallback.event;307308private pendingCallbacks = new Set<number>();309private lastTimeChecked = Date.now();310private checkCallbacksTimeout: Timeout | undefined = undefined;311private onDidChangeLocalStorageDisposable: IDisposable | undefined;312313constructor(private readonly _callbackRoute: string) {314super();315}316317create(options: Partial<UriComponents> = {}): URI {318const id = ++LocalStorageURLCallbackProvider.REQUEST_ID;319const queryParams: string[] = [`vscode-reqid=${id}`];320321for (const key of LocalStorageURLCallbackProvider.QUERY_KEYS) {322const value = options[key];323324if (value) {325queryParams.push(`vscode-${key}=${encodeURIComponent(value)}`);326}327}328329// TODO@joao remove eventually330// https://github.com/microsoft/vscode-dev/issues/62331// https://github.com/microsoft/vscode/blob/159479eb5ae451a66b5dac3c12d564f32f454796/extensions/github-authentication/src/githubServer.ts#L50-L50332if (!(options.authority === 'vscode.github-authentication' && options.path === '/dummy')) {333const key = `vscode-web.url-callbacks[${id}]`;334localStorage.removeItem(key);335336this.pendingCallbacks.add(id);337this.startListening();338}339340return URI.parse(mainWindow.location.href).with({ path: this._callbackRoute, query: queryParams.join('&') });341}342343private startListening(): void {344if (this.onDidChangeLocalStorageDisposable) {345return;346}347348this.onDidChangeLocalStorageDisposable = addDisposableListener(mainWindow, 'storage', () => this.onDidChangeLocalStorage());349}350351private stopListening(): void {352this.onDidChangeLocalStorageDisposable?.dispose();353this.onDidChangeLocalStorageDisposable = undefined;354}355356// this fires every time local storage changes, but we357// don't want to check more often than once a second358private async onDidChangeLocalStorage(): Promise<void> {359const ellapsed = Date.now() - this.lastTimeChecked;360361if (ellapsed > 1000) {362this.checkCallbacks();363} else if (this.checkCallbacksTimeout === undefined) {364this.checkCallbacksTimeout = setTimeout(() => {365this.checkCallbacksTimeout = undefined;366this.checkCallbacks();367}, 1000 - ellapsed);368}369}370371private checkCallbacks(): void {372let pendingCallbacks: Set<number> | undefined;373374for (const id of this.pendingCallbacks) {375const key = `vscode-web.url-callbacks[${id}]`;376const result = localStorage.getItem(key);377378if (result !== null) {379try {380this._onCallback.fire(URI.revive(JSON.parse(result)));381} catch (error) {382console.error(error);383}384385pendingCallbacks = pendingCallbacks ?? new Set(this.pendingCallbacks);386pendingCallbacks.delete(id);387localStorage.removeItem(key);388}389}390391if (pendingCallbacks) {392this.pendingCallbacks = pendingCallbacks;393394if (this.pendingCallbacks.size === 0) {395this.stopListening();396}397}398399this.lastTimeChecked = Date.now();400}401}402403class WorkspaceProvider implements IWorkspaceProvider {404405private static QUERY_PARAM_EMPTY_WINDOW = 'ew';406private static QUERY_PARAM_FOLDER = 'folder';407private static QUERY_PARAM_WORKSPACE = 'workspace';408409private static QUERY_PARAM_PAYLOAD = 'payload';410411static create(config: IWorkbenchConstructionOptions & { folderUri?: UriComponents; workspaceUri?: UriComponents }) {412let foundWorkspace = false;413let workspace: IWorkspace;414let payload = Object.create(null);415416const query = new URL(document.location.href).searchParams;417query.forEach((value, key) => {418switch (key) {419420// Folder421case WorkspaceProvider.QUERY_PARAM_FOLDER:422if (config.remoteAuthority && value.startsWith(posix.sep)) {423// when connected to a remote and having a value424// that is a path (begins with a `/`), assume this425// is a vscode-remote resource as simplified URL.426workspace = { folderUri: URI.from({ scheme: Schemas.vscodeRemote, path: value, authority: config.remoteAuthority }) };427} else {428workspace = { folderUri: URI.parse(value) };429}430foundWorkspace = true;431break;432433// Workspace434case WorkspaceProvider.QUERY_PARAM_WORKSPACE:435if (config.remoteAuthority && value.startsWith(posix.sep)) {436// when connected to a remote and having a value437// that is a path (begins with a `/`), assume this438// is a vscode-remote resource as simplified URL.439workspace = { workspaceUri: URI.from({ scheme: Schemas.vscodeRemote, path: value, authority: config.remoteAuthority }) };440} else {441workspace = { workspaceUri: URI.parse(value) };442}443foundWorkspace = true;444break;445446// Empty447case WorkspaceProvider.QUERY_PARAM_EMPTY_WINDOW:448workspace = undefined;449foundWorkspace = true;450break;451452// Payload453case WorkspaceProvider.QUERY_PARAM_PAYLOAD:454try {455payload = parse(value); // use marshalling#parse() to revive potential URIs456} catch (error) {457console.error(error); // possible invalid JSON458}459break;460}461});462463// If no workspace is provided through the URL, check for config464// attribute from server465if (!foundWorkspace) {466if (config.folderUri) {467workspace = { folderUri: URI.revive(config.folderUri) };468} else if (config.workspaceUri) {469workspace = { workspaceUri: URI.revive(config.workspaceUri) };470}471}472473return new WorkspaceProvider(workspace, payload, config);474}475476readonly trusted = true;477478private constructor(479readonly workspace: IWorkspace,480readonly payload: object,481private readonly config: IWorkbenchConstructionOptions482) {483}484485async open(workspace: IWorkspace, options?: { reuse?: boolean; payload?: object }): Promise<boolean> {486if (options?.reuse && !options.payload && this.isSame(this.workspace, workspace)) {487return true; // return early if workspace and environment is not changing and we are reusing window488}489490const targetHref = this.createTargetUrl(workspace, options);491if (targetHref) {492if (options?.reuse) {493mainWindow.location.href = targetHref;494return true;495} else {496let result;497if (isStandalone()) {498result = mainWindow.open(targetHref, '_blank', 'toolbar=no'); // ensures to open another 'standalone' window!499} else {500result = mainWindow.open(targetHref);501}502503return !!result;504}505}506507return false;508}509510private createTargetUrl(workspace: IWorkspace, options?: { reuse?: boolean; payload?: object }): string | undefined {511512// Empty513let targetHref: string | undefined = undefined;514if (!workspace) {515targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_EMPTY_WINDOW}=true`;516}517518// Folder519else if (isFolderToOpen(workspace)) {520const queryParamFolder = this.encodeWorkspacePath(workspace.folderUri);521targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_FOLDER}=${queryParamFolder}`;522}523524// Workspace525else if (isWorkspaceToOpen(workspace)) {526const queryParamWorkspace = this.encodeWorkspacePath(workspace.workspaceUri);527targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_WORKSPACE}=${queryParamWorkspace}`;528}529530// Append payload if any531if (options?.payload) {532targetHref += `&${WorkspaceProvider.QUERY_PARAM_PAYLOAD}=${encodeURIComponent(JSON.stringify(options.payload))}`;533}534535return targetHref;536}537538private encodeWorkspacePath(uri: URI): string {539if (this.config.remoteAuthority && uri.scheme === Schemas.vscodeRemote) {540541// when connected to a remote and having a folder542// or workspace for that remote, only use the path543// as query value to form shorter, nicer URLs.544// however, we still need to `encodeURIComponent`545// to ensure to preserve special characters, such546// as `+` in the path.547548return encodeURIComponent(`${posix.sep}${ltrim(uri.path, posix.sep)}`).replaceAll('%2F', '/');549}550551return encodeURIComponent(uri.toString(true));552}553554private isSame(workspaceA: IWorkspace, workspaceB: IWorkspace): boolean {555if (!workspaceA || !workspaceB) {556return workspaceA === workspaceB; // both empty557}558559if (isFolderToOpen(workspaceA) && isFolderToOpen(workspaceB)) {560return isEqual(workspaceA.folderUri, workspaceB.folderUri); // same workspace561}562563if (isWorkspaceToOpen(workspaceA) && isWorkspaceToOpen(workspaceB)) {564return isEqual(workspaceA.workspaceUri, workspaceB.workspaceUri); // same workspace565}566567return false;568}569570hasRemote(): boolean {571if (this.workspace) {572if (isFolderToOpen(this.workspace)) {573return this.workspace.folderUri.scheme === Schemas.vscodeRemote;574}575576if (isWorkspaceToOpen(this.workspace)) {577return this.workspace.workspaceUri.scheme === Schemas.vscodeRemote;578}579}580581return true;582}583}584585function readCookie(name: string): string | undefined {586const cookies = document.cookie.split('; ');587for (const cookie of cookies) {588if (cookie.startsWith(name + '=')) {589return cookie.substring(name.length + 1);590}591}592593return undefined;594}595596(function () {597598// Find config by checking for DOM599const configElement = mainWindow.document.getElementById('vscode-workbench-web-configuration');600const configElementAttribute = configElement ? configElement.getAttribute('data-settings') : undefined;601if (!configElement || !configElementAttribute) {602throw new Error('Missing web configuration element');603}604const config: IWorkbenchConstructionOptions & { folderUri?: UriComponents; workspaceUri?: UriComponents; callbackRoute: string } = JSON.parse(configElementAttribute);605const secretStorageKeyPath = readCookie('vscode-secret-key-path');606const secretStorageCrypto = secretStorageKeyPath && ServerKeyedAESCrypto.supported()607? new ServerKeyedAESCrypto(secretStorageKeyPath) : new TransparentCrypto();608609// Create workbench610create(mainWindow.document.body, {611...config,612windowIndicator: config.windowIndicator ?? { label: '$(remote)', tooltip: `${product.nameShort} Web` },613settingsSyncOptions: config.settingsSyncOptions ? { enabled: config.settingsSyncOptions.enabled, } : undefined,614workspaceProvider: WorkspaceProvider.create(config),615urlCallbackProvider: new LocalStorageURLCallbackProvider(config.callbackRoute),616secretStorageProvider: config.remoteAuthority && !secretStorageKeyPath617? undefined /* with a remote without embedder-preferred storage, store on the remote */618: new LocalStorageSecretStorageProvider(secretStorageCrypto),619});620})();621622623