vscode/src/vs/code/browser/workbench/workbench.ts
mxwj 018ff30de3
Some checks failed
Monaco Editor checks / Monaco Editor checks (push) Has been cancelled
Initial commit
2024-11-15 14:29:18 +08:00

602 lines
20 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* 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<string>;
unseal(data: string): Promise<string>;
}
class TransparentCrypto implements ISecretStorageCrypto {
async seal(data: string): Promise<string> {
return data;
}
async unseal(data: string): Promise<string> {
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<string> {
// 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<string> {
// 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<CryptoKey> {
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<Uint8Array> {
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<Record<string, string>> = this.load();
type: 'in-memory' | 'persisted' | 'unknown' = 'persisted';
constructor(
private readonly crypto: ISecretStorageCrypto,
) { }
private async load(): Promise<Record<string, string>> {
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<string, string> {
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<string, string> = {};
// 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<string | undefined> {
const secrets = await this._secretsPromise;
return secrets[key];
}
async set(key: string, value: string): Promise<void> {
const secrets = await this._secretsPromise;
secrets[key] = value;
this._secretsPromise = Promise.resolve(secrets);
this.save();
}
async delete(key: string): Promise<void> {
const secrets = await this._secretsPromise;
delete secrets[key];
this._secretsPromise = Promise.resolve(secrets);
this.save();
}
private async save(): Promise<void> {
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<URI>());
readonly onCallback = this._onCallback.event;
private pendingCallbacks = new Set<number>();
private lastTimeChecked = Date.now();
private checkCallbacksTimeout: unknown | undefined = undefined;
private onDidChangeLocalStorageDisposable: IDisposable | undefined;
constructor(private readonly _callbackRoute: string) {
super();
}
create(options: Partial<UriComponents> = {}): 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<void> {
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<number> | 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<boolean> {
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),
});
})();