/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { isStandalone } from '../../../base/browser/browser.js'; import { mainWindow } from '../../../base/browser/window.js'; import { VSBuffer, decodeBase64, encodeBase64 } from '../../../base/common/buffer.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../base/common/lifecycle.js'; import { parse } from '../../../base/common/marshalling.js'; import { Schemas } from '../../../base/common/network.js'; import { posix } from '../../../base/common/path.js'; import { isEqual } from '../../../base/common/resources.js'; import { ltrim } from '../../../base/common/strings.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import product from '../../../platform/product/common/product.js'; import { ISecretStorageProvider } from '../../../platform/secrets/common/secrets.js'; import { isFolderToOpen, isWorkspaceToOpen } from '../../../platform/window/common/window.js'; import type { IWorkbenchConstructionOptions, IWorkspace, IWorkspaceProvider } from '../../../workbench/browser/web.api.js'; import { AuthenticationSessionInfo } from '../../../workbench/services/authentication/browser/authenticationService.js'; import type { IURLCallbackProvider } from '../../../workbench/services/url/browser/urlService.js'; import { create } from '../../../workbench/workbench.web.main.internal.js'; interface ISecretStorageCrypto { seal(data: string): Promise; unseal(data: string): Promise; } class TransparentCrypto implements ISecretStorageCrypto { async seal(data: string): Promise { return data; } async unseal(data: string): Promise { return data; } } const enum AESConstants { ALGORITHM = 'AES-GCM', KEY_LENGTH = 256, IV_LENGTH = 12, } class NetworkError extends Error { constructor(inner: Error) { super(inner.message); this.name = inner.name; this.stack = inner.stack; } } class ServerKeyedAESCrypto implements ISecretStorageCrypto { private _serverKey: Uint8Array | undefined; /** Gets whether the algorithm is supported; requires a secure context */ public static supported() { return !!crypto.subtle; } constructor(private readonly authEndpoint: string) { } async seal(data: string): Promise { // Get a new key and IV on every change, to avoid the risk of reusing the same key and IV pair with AES-GCM // (see also: https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams#properties) const iv = mainWindow.crypto.getRandomValues(new Uint8Array(AESConstants.IV_LENGTH)); // 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 const clientKeyObj = await mainWindow.crypto.subtle.generateKey( { name: AESConstants.ALGORITHM as const, length: AESConstants.KEY_LENGTH as const }, true, ['encrypt', 'decrypt'] ); const clientKey = new Uint8Array(await mainWindow.crypto.subtle.exportKey('raw', clientKeyObj)); const key = await this.getKey(clientKey); const dataUint8Array = new TextEncoder().encode(data); const cipherText: ArrayBuffer = await mainWindow.crypto.subtle.encrypt( { name: AESConstants.ALGORITHM as const, iv }, key, dataUint8Array ); // Base64 encode the result and store the ciphertext, the key, and the IV in localStorage // Note that the clientKey and IV don't need to be secret const result = new Uint8Array([...clientKey, ...iv, ...new Uint8Array(cipherText)]); return encodeBase64(VSBuffer.wrap(result)); } async unseal(data: string): Promise { // 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) // Minimum length must be 44 (key+IV length) + 16 bytes (1 block encrypted with AES - regardless of key size) const dataUint8Array = decodeBase64(data); if (dataUint8Array.byteLength < 60) { throw Error('Invalid length for the value for credentials.crypto'); } const keyLength = AESConstants.KEY_LENGTH / 8; const clientKey = dataUint8Array.slice(0, keyLength); const iv = dataUint8Array.slice(keyLength, keyLength + AESConstants.IV_LENGTH); const cipherText = dataUint8Array.slice(keyLength + AESConstants.IV_LENGTH); // Do the decryption and parse the result as JSON const key = await this.getKey(clientKey.buffer); const decrypted = await mainWindow.crypto.subtle.decrypt( { name: AESConstants.ALGORITHM as const, iv: iv.buffer }, key, cipherText.buffer ); return new TextDecoder().decode(new Uint8Array(decrypted)); } /** * Given a clientKey, returns the CryptoKey object that is used to encrypt/decrypt the data. * The actual key is (clientKey XOR serverKey) */ private async getKey(clientKey: Uint8Array): Promise { if (!clientKey || clientKey.byteLength !== AESConstants.KEY_LENGTH / 8) { throw Error('Invalid length for clientKey'); } const serverKey = await this.getServerKeyPart(); const keyData = new Uint8Array(AESConstants.KEY_LENGTH / 8); for (let i = 0; i < keyData.byteLength; i++) { keyData[i] = clientKey[i]! ^ serverKey[i]!; } return mainWindow.crypto.subtle.importKey( 'raw', keyData, { name: AESConstants.ALGORITHM as const, length: AESConstants.KEY_LENGTH as const, }, true, ['encrypt', 'decrypt'] ); } private async getServerKeyPart(): Promise { if (this._serverKey) { return this._serverKey; } let attempt = 0; let lastError: Error | undefined; while (attempt <= 3) { try { const res = await fetch(this.authEndpoint, { credentials: 'include', method: 'POST' }); if (!res.ok) { throw new Error(res.statusText); } const serverKey = new Uint8Array(await res.arrayBuffer()); if (serverKey.byteLength !== AESConstants.KEY_LENGTH / 8) { throw Error(`The key retrieved by the server is not ${AESConstants.KEY_LENGTH} bit long.`); } this._serverKey = serverKey; return this._serverKey; } catch (e) { lastError = e instanceof Error ? e : new Error(String(e)); attempt++; // exponential backoff await new Promise(resolve => setTimeout(resolve, attempt * attempt * 100)); } } if (lastError) { throw new NetworkError(lastError); } throw new Error('Unknown error'); } } export class LocalStorageSecretStorageProvider implements ISecretStorageProvider { private readonly _storageKey = 'secrets.provider'; private _secretsPromise: Promise> = this.load(); type: 'in-memory' | 'persisted' | 'unknown' = 'persisted'; constructor( private readonly crypto: ISecretStorageCrypto, ) { } private async load(): Promise> { const record = this.loadAuthSessionFromElement(); // Get the secrets from localStorage const encrypted = localStorage.getItem(this._storageKey); if (encrypted) { try { const decrypted = JSON.parse(await this.crypto.unseal(encrypted)); return { ...record, ...decrypted }; } catch (err) { // TODO: send telemetry console.error('Failed to decrypt secrets from localStorage', err); if (!(err instanceof NetworkError)) { localStorage.removeItem(this._storageKey); } } } return record; } private loadAuthSessionFromElement(): Record { let authSessionInfo: (AuthenticationSessionInfo & { scopes: string[][] }) | undefined; const authSessionElement = mainWindow.document.getElementById('vscode-workbench-auth-session'); const authSessionElementAttribute = authSessionElement ? authSessionElement.getAttribute('data-settings') : undefined; if (authSessionElementAttribute) { try { authSessionInfo = JSON.parse(authSessionElementAttribute); } catch (error) { /* Invalid session is passed. Ignore. */ } } if (!authSessionInfo) { return {}; } const record: Record = {}; // Settings Sync Entry record[`${product.urlProtocol}.loginAccount`] = JSON.stringify(authSessionInfo); // Auth extension Entry if (authSessionInfo.providerId !== 'github') { console.error(`Unexpected auth provider: ${authSessionInfo.providerId}. Expected 'github'.`); return record; } const authAccount = JSON.stringify({ extensionId: 'vscode.github-authentication', key: 'github.auth' }); record[authAccount] = JSON.stringify(authSessionInfo.scopes.map(scopes => ({ id: authSessionInfo.id, scopes, accessToken: authSessionInfo.accessToken }))); return record; } async get(key: string): Promise { const secrets = await this._secretsPromise; return secrets[key]; } async set(key: string, value: string): Promise { const secrets = await this._secretsPromise; secrets[key] = value; this._secretsPromise = Promise.resolve(secrets); this.save(); } async delete(key: string): Promise { const secrets = await this._secretsPromise; delete secrets[key]; this._secretsPromise = Promise.resolve(secrets); this.save(); } private async save(): Promise { try { const encrypted = await this.crypto.seal(JSON.stringify(await this._secretsPromise)); localStorage.setItem(this._storageKey, encrypted); } catch (err) { console.error(err); } } } class LocalStorageURLCallbackProvider extends Disposable implements IURLCallbackProvider { private static REQUEST_ID = 0; private static QUERY_KEYS: ('scheme' | 'authority' | 'path' | 'query' | 'fragment')[] = [ 'scheme', 'authority', 'path', 'query', 'fragment' ]; private readonly _onCallback = this._register(new Emitter()); readonly onCallback = this._onCallback.event; private pendingCallbacks = new Set(); private lastTimeChecked = Date.now(); private checkCallbacksTimeout: unknown | undefined = undefined; private onDidChangeLocalStorageDisposable: IDisposable | undefined; constructor(private readonly _callbackRoute: string) { super(); } create(options: Partial = {}): URI { const id = ++LocalStorageURLCallbackProvider.REQUEST_ID; const queryParams: string[] = [`vscode-reqid=${id}`]; for (const key of LocalStorageURLCallbackProvider.QUERY_KEYS) { const value = options[key]; if (value) { queryParams.push(`vscode-${key}=${encodeURIComponent(value)}`); } } // TODO@joao remove eventually // https://github.com/microsoft/vscode-dev/issues/62 // https://github.com/microsoft/vscode/blob/159479eb5ae451a66b5dac3c12d564f32f454796/extensions/github-authentication/src/githubServer.ts#L50-L50 if (!(options.authority === 'vscode.github-authentication' && options.path === '/dummy')) { const key = `vscode-web.url-callbacks[${id}]`; localStorage.removeItem(key); this.pendingCallbacks.add(id); this.startListening(); } return URI.parse(mainWindow.location.href).with({ path: this._callbackRoute, query: queryParams.join('&') }); } private startListening(): void { if (this.onDidChangeLocalStorageDisposable) { return; } const fn = () => this.onDidChangeLocalStorage(); mainWindow.addEventListener('storage', fn); this.onDidChangeLocalStorageDisposable = { dispose: () => mainWindow.removeEventListener('storage', fn) }; } private stopListening(): void { this.onDidChangeLocalStorageDisposable?.dispose(); this.onDidChangeLocalStorageDisposable = undefined; } // this fires every time local storage changes, but we // don't want to check more often than once a second private async onDidChangeLocalStorage(): Promise { const ellapsed = Date.now() - this.lastTimeChecked; if (ellapsed > 1000) { this.checkCallbacks(); } else if (this.checkCallbacksTimeout === undefined) { this.checkCallbacksTimeout = setTimeout(() => { this.checkCallbacksTimeout = undefined; this.checkCallbacks(); }, 1000 - ellapsed); } } private checkCallbacks(): void { let pendingCallbacks: Set | undefined; for (const id of this.pendingCallbacks) { const key = `vscode-web.url-callbacks[${id}]`; const result = localStorage.getItem(key); if (result !== null) { try { this._onCallback.fire(URI.revive(JSON.parse(result))); } catch (error) { console.error(error); } pendingCallbacks = pendingCallbacks ?? new Set(this.pendingCallbacks); pendingCallbacks.delete(id); localStorage.removeItem(key); } } if (pendingCallbacks) { this.pendingCallbacks = pendingCallbacks; if (this.pendingCallbacks.size === 0) { this.stopListening(); } } this.lastTimeChecked = Date.now(); } } class WorkspaceProvider implements IWorkspaceProvider { private static QUERY_PARAM_EMPTY_WINDOW = 'ew'; private static QUERY_PARAM_FOLDER = 'folder'; private static QUERY_PARAM_WORKSPACE = 'workspace'; private static QUERY_PARAM_PAYLOAD = 'payload'; static create(config: IWorkbenchConstructionOptions & { folderUri?: UriComponents; workspaceUri?: UriComponents }) { let foundWorkspace = false; let workspace: IWorkspace; let payload = Object.create(null); const query = new URL(document.location.href).searchParams; query.forEach((value, key) => { switch (key) { // Folder case WorkspaceProvider.QUERY_PARAM_FOLDER: if (config.remoteAuthority && value.startsWith(posix.sep)) { // when connected to a remote and having a value // that is a path (begins with a `/`), assume this // is a vscode-remote resource as simplified URL. workspace = { folderUri: URI.from({ scheme: Schemas.vscodeRemote, path: value, authority: config.remoteAuthority }) }; } else { workspace = { folderUri: URI.parse(value) }; } foundWorkspace = true; break; // Workspace case WorkspaceProvider.QUERY_PARAM_WORKSPACE: if (config.remoteAuthority && value.startsWith(posix.sep)) { // when connected to a remote and having a value // that is a path (begins with a `/`), assume this // is a vscode-remote resource as simplified URL. workspace = { workspaceUri: URI.from({ scheme: Schemas.vscodeRemote, path: value, authority: config.remoteAuthority }) }; } else { workspace = { workspaceUri: URI.parse(value) }; } foundWorkspace = true; break; // Empty case WorkspaceProvider.QUERY_PARAM_EMPTY_WINDOW: workspace = undefined; foundWorkspace = true; break; // Payload case WorkspaceProvider.QUERY_PARAM_PAYLOAD: try { payload = parse(value); // use marshalling#parse() to revive potential URIs } catch (error) { console.error(error); // possible invalid JSON } break; } }); // If no workspace is provided through the URL, check for config // attribute from server if (!foundWorkspace) { if (config.folderUri) { workspace = { folderUri: URI.revive(config.folderUri) }; } else if (config.workspaceUri) { workspace = { workspaceUri: URI.revive(config.workspaceUri) }; } } return new WorkspaceProvider(workspace, payload, config); } readonly trusted = true; private constructor( readonly workspace: IWorkspace, readonly payload: object, private readonly config: IWorkbenchConstructionOptions ) { } async open(workspace: IWorkspace, options?: { reuse?: boolean; payload?: object }): Promise { if (options?.reuse && !options.payload && this.isSame(this.workspace, workspace)) { return true; // return early if workspace and environment is not changing and we are reusing window } const targetHref = this.createTargetUrl(workspace, options); if (targetHref) { if (options?.reuse) { mainWindow.location.href = targetHref; return true; } else { let result; if (isStandalone()) { result = mainWindow.open(targetHref, '_blank', 'toolbar=no'); // ensures to open another 'standalone' window! } else { result = mainWindow.open(targetHref); } return !!result; } } return false; } private createTargetUrl(workspace: IWorkspace, options?: { reuse?: boolean; payload?: object }): string | undefined { // Empty let targetHref: string | undefined = undefined; if (!workspace) { targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_EMPTY_WINDOW}=true`; } // Folder else if (isFolderToOpen(workspace)) { const queryParamFolder = this.encodeWorkspacePath(workspace.folderUri); targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_FOLDER}=${queryParamFolder}`; } // Workspace else if (isWorkspaceToOpen(workspace)) { const queryParamWorkspace = this.encodeWorkspacePath(workspace.workspaceUri); targetHref = `${document.location.origin}${document.location.pathname}?${WorkspaceProvider.QUERY_PARAM_WORKSPACE}=${queryParamWorkspace}`; } // Append payload if any if (options?.payload) { targetHref += `&${WorkspaceProvider.QUERY_PARAM_PAYLOAD}=${encodeURIComponent(JSON.stringify(options.payload))}`; } return targetHref; } private encodeWorkspacePath(uri: URI): string { if (this.config.remoteAuthority && uri.scheme === Schemas.vscodeRemote) { // when connected to a remote and having a folder // or workspace for that remote, only use the path // as query value to form shorter, nicer URLs. // however, we still need to `encodeURIComponent` // to ensure to preserve special characters, such // as `+` in the path. return encodeURIComponent(`${posix.sep}${ltrim(uri.path, posix.sep)}`).replaceAll('%2F', '/'); } return encodeURIComponent(uri.toString(true)); } private isSame(workspaceA: IWorkspace, workspaceB: IWorkspace): boolean { if (!workspaceA || !workspaceB) { return workspaceA === workspaceB; // both empty } if (isFolderToOpen(workspaceA) && isFolderToOpen(workspaceB)) { return isEqual(workspaceA.folderUri, workspaceB.folderUri); // same workspace } if (isWorkspaceToOpen(workspaceA) && isWorkspaceToOpen(workspaceB)) { return isEqual(workspaceA.workspaceUri, workspaceB.workspaceUri); // same workspace } return false; } hasRemote(): boolean { if (this.workspace) { if (isFolderToOpen(this.workspace)) { return this.workspace.folderUri.scheme === Schemas.vscodeRemote; } if (isWorkspaceToOpen(this.workspace)) { return this.workspace.workspaceUri.scheme === Schemas.vscodeRemote; } } return true; } } function readCookie(name: string): string | undefined { const cookies = document.cookie.split('; '); for (const cookie of cookies) { if (cookie.startsWith(name + '=')) { return cookie.substring(name.length + 1); } } return undefined; } (function () { // Find config by checking for DOM const configElement = mainWindow.document.getElementById('vscode-workbench-web-configuration'); const configElementAttribute = configElement ? configElement.getAttribute('data-settings') : undefined; if (!configElement || !configElementAttribute) { throw new Error('Missing web configuration element'); } const config: IWorkbenchConstructionOptions & { folderUri?: UriComponents; workspaceUri?: UriComponents; callbackRoute: string } = JSON.parse(configElementAttribute); const secretStorageKeyPath = readCookie('vscode-secret-key-path'); const secretStorageCrypto = secretStorageKeyPath && ServerKeyedAESCrypto.supported() ? new ServerKeyedAESCrypto(secretStorageKeyPath) : new TransparentCrypto(); // Create workbench create(mainWindow.document.body, { ...config, windowIndicator: config.windowIndicator ?? { label: '$(remote)', tooltip: `${product.nameShort} Web` }, settingsSyncOptions: config.settingsSyncOptions ? { enabled: config.settingsSyncOptions.enabled, } : undefined, workspaceProvider: WorkspaceProvider.create(config), urlCallbackProvider: new LocalStorageURLCallbackProvider(config.callbackRoute), secretStorageProvider: config.remoteAuthority && !secretStorageKeyPath ? undefined /* with a remote without embedder-preferred storage, store on the remote */ : new LocalStorageSecretStorageProvider(secretStorageCrypto), }); })();