Path: blob/main/src/vs/code/browser/workbench/workbench.ts
5237 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;225// eslint-disable-next-line no-restricted-syntax226const authSessionElement = mainWindow.document.getElementById('vscode-workbench-auth-session');227const authSessionElementAttribute = authSessionElement ? authSessionElement.getAttribute('data-settings') : undefined;228if (authSessionElementAttribute) {229try {230authSessionInfo = JSON.parse(authSessionElementAttribute);231} catch (error) { /* Invalid session is passed. Ignore. */ }232}233234if (!authSessionInfo) {235return {};236}237238const record: Record<string, string> = {};239240// Settings Sync Entry241record[`${product.urlProtocol}.loginAccount`] = JSON.stringify(authSessionInfo);242243// Auth extension Entry244if (authSessionInfo.providerId !== 'github') {245console.error(`Unexpected auth provider: ${authSessionInfo.providerId}. Expected 'github'.`);246return record;247}248249const authAccount = JSON.stringify({ extensionId: 'vscode.github-authentication', key: 'github.auth' });250record[authAccount] = JSON.stringify(authSessionInfo.scopes.map(scopes => ({251id: authSessionInfo.id,252scopes,253accessToken: authSessionInfo.accessToken254})));255256return record;257}258259async get(key: string): Promise<string | undefined> {260const secrets = await this.secretsPromise;261262return secrets[key];263}264265async set(key: string, value: string): Promise<void> {266const secrets = await this.secretsPromise;267secrets[key] = value;268this.secretsPromise = Promise.resolve(secrets);269this.save();270}271272async delete(key: string): Promise<void> {273const secrets = await this.secretsPromise;274delete secrets[key];275this.secretsPromise = Promise.resolve(secrets);276this.save();277}278279async keys(): Promise<string[]> {280const secrets = await this.secretsPromise;281return Object.keys(secrets) || [];282}283284private async save(): Promise<void> {285try {286const encrypted = await this.crypto.seal(JSON.stringify(await this.secretsPromise));287localStorage.setItem(this.storageKey, encrypted);288} catch (err) {289console.error(err);290}291}292}293294class LocalStorageURLCallbackProvider extends Disposable implements IURLCallbackProvider {295296private static REQUEST_ID = 0;297298private static QUERY_KEYS: ('scheme' | 'authority' | 'path' | 'query' | 'fragment')[] = [299'scheme',300'authority',301'path',302'query',303'fragment'304];305306private readonly _onCallback = this._register(new Emitter<URI>());307readonly onCallback = this._onCallback.event;308309private pendingCallbacks = new Set<number>();310private lastTimeChecked = Date.now();311private checkCallbacksTimeout: Timeout | undefined = undefined;312private onDidChangeLocalStorageDisposable: IDisposable | undefined;313314constructor(private readonly _callbackRoute: string) {315super();316}317318create(options: Partial<UriComponents> = {}): URI {319const id = ++LocalStorageURLCallbackProvider.REQUEST_ID;320const queryParams: string[] = [`vscode-reqid=${id}`];321322for (const key of LocalStorageURLCallbackProvider.QUERY_KEYS) {323const value = options[key];324325if (value) {326queryParams.push(`vscode-${key}=${encodeURIComponent(value)}`);327}328}329330// TODO@joao remove eventually331// https://github.com/microsoft/vscode-dev/issues/62332// https://github.com/microsoft/vscode/blob/159479eb5ae451a66b5dac3c12d564f32f454796/extensions/github-authentication/src/githubServer.ts#L50-L50333if (!(options.authority === 'vscode.github-authentication' && options.path === '/dummy')) {334const key = `vscode-web.url-callbacks[${id}]`;335localStorage.removeItem(key);336337this.pendingCallbacks.add(id);338this.startListening();339}340341return URI.parse(mainWindow.location.href).with({ path: this._callbackRoute, query: queryParams.join('&') });342}343344private startListening(): void {345if (this.onDidChangeLocalStorageDisposable) {346return;347}348349this.onDidChangeLocalStorageDisposable = addDisposableListener(mainWindow, 'storage', () => this.onDidChangeLocalStorage());350}351352private stopListening(): void {353this.onDidChangeLocalStorageDisposable?.dispose();354this.onDidChangeLocalStorageDisposable = undefined;355}356357// this fires every time local storage changes, but we358// don't want to check more often than once a second359private async onDidChangeLocalStorage(): Promise<void> {360const ellapsed = Date.now() - this.lastTimeChecked;361362if (ellapsed > 1000) {363this.checkCallbacks();364} else if (this.checkCallbacksTimeout === undefined) {365this.checkCallbacksTimeout = setTimeout(() => {366this.checkCallbacksTimeout = undefined;367this.checkCallbacks();368}, 1000 - ellapsed);369}370}371372private checkCallbacks(): void {373let pendingCallbacks: Set<number> | undefined;374375for (const id of this.pendingCallbacks) {376const key = `vscode-web.url-callbacks[${id}]`;377const result = localStorage.getItem(key);378379if (result !== null) {380try {381this._onCallback.fire(URI.revive(JSON.parse(result)));382} catch (error) {383console.error(error);384}385386pendingCallbacks = pendingCallbacks ?? new Set(this.pendingCallbacks);387pendingCallbacks.delete(id);388localStorage.removeItem(key);389}390}391392if (pendingCallbacks) {393this.pendingCallbacks = pendingCallbacks;394395if (this.pendingCallbacks.size === 0) {396this.stopListening();397}398}399400this.lastTimeChecked = Date.now();401}402}403404class WorkspaceProvider implements IWorkspaceProvider {405406private static QUERY_PARAM_EMPTY_WINDOW = 'ew';407private static QUERY_PARAM_FOLDER = 'folder';408private static QUERY_PARAM_WORKSPACE = 'workspace';409410private static QUERY_PARAM_PAYLOAD = 'payload';411412static create(config: IWorkbenchConstructionOptions & { folderUri?: UriComponents; workspaceUri?: UriComponents }) {413let foundWorkspace = false;414let workspace: IWorkspace;415let payload = Object.create(null);416417const query = new URL(document.location.href).searchParams;418query.forEach((value, key) => {419switch (key) {420421// Folder422case WorkspaceProvider.QUERY_PARAM_FOLDER:423if (config.remoteAuthority && value.startsWith(posix.sep)) {424// when connected to a remote and having a value425// that is a path (begins with a `/`), assume this426// is a vscode-remote resource as simplified URL.427workspace = { folderUri: URI.from({ scheme: Schemas.vscodeRemote, path: value, authority: config.remoteAuthority }) };428} else {429workspace = { folderUri: URI.parse(value) };430}431foundWorkspace = true;432break;433434// Workspace435case WorkspaceProvider.QUERY_PARAM_WORKSPACE:436if (config.remoteAuthority && value.startsWith(posix.sep)) {437// when connected to a remote and having a value438// that is a path (begins with a `/`), assume this439// is a vscode-remote resource as simplified URL.440workspace = { workspaceUri: URI.from({ scheme: Schemas.vscodeRemote, path: value, authority: config.remoteAuthority }) };441} else {442workspace = { workspaceUri: URI.parse(value) };443}444foundWorkspace = true;445break;446447// Empty448case WorkspaceProvider.QUERY_PARAM_EMPTY_WINDOW:449workspace = undefined;450foundWorkspace = true;451break;452453// Payload454case WorkspaceProvider.QUERY_PARAM_PAYLOAD:455try {456payload = parse(value); // use marshalling#parse() to revive potential URIs457} catch (error) {458console.error(error); // possible invalid JSON459}460break;461}462});463464// If no workspace is provided through the URL, check for config465// attribute from server466if (!foundWorkspace) {467if (config.folderUri) {468workspace = { folderUri: URI.revive(config.folderUri) };469} else if (config.workspaceUri) {470workspace = { workspaceUri: URI.revive(config.workspaceUri) };471}472}473474return new WorkspaceProvider(workspace, payload, config);475}476477readonly trusted = true;478479private constructor(480readonly workspace: IWorkspace,481readonly payload: object,482private readonly config: IWorkbenchConstructionOptions483) {484}485486async open(workspace: IWorkspace, options?: { reuse?: boolean; payload?: object }): Promise<boolean> {487if (options?.reuse && !options.payload && this.isSame(this.workspace, workspace)) {488return true; // return early if workspace and environment is not changing and we are reusing window489}490491const targetHref = this.createTargetUrl(workspace, options);492if (targetHref) {493if (options?.reuse) {494mainWindow.location.href = targetHref;495return true;496} else {497let result;498if (isStandalone()) {499result = mainWindow.open(targetHref, '_blank', 'toolbar=no'); // ensures to open another 'standalone' window!500} else {501result = mainWindow.open(targetHref);502}503504return !!result;505}506}507508return false;509}510511private createTargetUrl(workspace: IWorkspace, options?: { reuse?: boolean; payload?: object }): string | undefined {512513// Empty514let targetHref: string | undefined = undefined;515if (!workspace) {516targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_EMPTY_WINDOW}=true`;517}518519// Folder520else if (isFolderToOpen(workspace)) {521const queryParamFolder = this.encodeWorkspacePath(workspace.folderUri);522targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_FOLDER}=${queryParamFolder}`;523}524525// Workspace526else if (isWorkspaceToOpen(workspace)) {527const queryParamWorkspace = this.encodeWorkspacePath(workspace.workspaceUri);528targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_WORKSPACE}=${queryParamWorkspace}`;529}530531// Append payload if any532if (options?.payload) {533targetHref += `&${WorkspaceProvider.QUERY_PARAM_PAYLOAD}=${encodeURIComponent(JSON.stringify(options.payload))}`;534}535536return targetHref;537}538539private encodeWorkspacePath(uri: URI): string {540if (this.config.remoteAuthority && uri.scheme === Schemas.vscodeRemote) {541542// when connected to a remote and having a folder543// or workspace for that remote, only use the path544// as query value to form shorter, nicer URLs.545// however, we still need to `encodeURIComponent`546// to ensure to preserve special characters, such547// as `+` in the path.548549return encodeURIComponent(`${posix.sep}${ltrim(uri.path, posix.sep)}`).replaceAll('%2F', '/');550}551552return encodeURIComponent(uri.toString(true));553}554555private isSame(workspaceA: IWorkspace, workspaceB: IWorkspace): boolean {556if (!workspaceA || !workspaceB) {557return workspaceA === workspaceB; // both empty558}559560if (isFolderToOpen(workspaceA) && isFolderToOpen(workspaceB)) {561return isEqual(workspaceA.folderUri, workspaceB.folderUri); // same workspace562}563564if (isWorkspaceToOpen(workspaceA) && isWorkspaceToOpen(workspaceB)) {565return isEqual(workspaceA.workspaceUri, workspaceB.workspaceUri); // same workspace566}567568return false;569}570571hasRemote(): boolean {572if (this.workspace) {573if (isFolderToOpen(this.workspace)) {574return this.workspace.folderUri.scheme === Schemas.vscodeRemote;575}576577if (isWorkspaceToOpen(this.workspace)) {578return this.workspace.workspaceUri.scheme === Schemas.vscodeRemote;579}580}581582return true;583}584}585586function readCookie(name: string): string | undefined {587const cookies = document.cookie.split('; ');588for (const cookie of cookies) {589if (cookie.startsWith(name + '=')) {590return cookie.substring(name.length + 1);591}592}593594return undefined;595}596597(function () {598599// Find config by checking for DOM600// eslint-disable-next-line no-restricted-syntax601const configElement = mainWindow.document.getElementById('vscode-workbench-web-configuration');602const configElementAttribute = configElement ? configElement.getAttribute('data-settings') : undefined;603if (!configElement || !configElementAttribute) {604throw new Error('Missing web configuration element');605}606const config: IWorkbenchConstructionOptions & { folderUri?: UriComponents; workspaceUri?: UriComponents; callbackRoute: string } = JSON.parse(configElementAttribute);607const secretStorageKeyPath = readCookie('vscode-secret-key-path');608const secretStorageCrypto = secretStorageKeyPath && ServerKeyedAESCrypto.supported()609? new ServerKeyedAESCrypto(secretStorageKeyPath) : new TransparentCrypto();610611// Create workbench612create(mainWindow.document.body, {613...config,614windowIndicator: config.windowIndicator ?? { label: '$(remote)', tooltip: `${product.nameShort} Web` },615settingsSyncOptions: config.settingsSyncOptions ? { enabled: config.settingsSyncOptions.enabled, } : undefined,616workspaceProvider: WorkspaceProvider.create(config),617urlCallbackProvider: new LocalStorageURLCallbackProvider(config.callbackRoute),618secretStorageProvider: config.remoteAuthority && !secretStorageKeyPath619? undefined /* with a remote without embedder-preferred storage, store on the remote */620: new LocalStorageSecretStorageProvider(secretStorageCrypto),621});622})();623624625