diff --git a/packages/core/package.json b/packages/core/package.json index 957af613bdeb1..71bec9023e3a9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -34,6 +34,7 @@ "ajv": "^6.5.3", "body-parser": "^1.17.2", "cookie": "^0.4.0", + "keytar": "7.7.0", "drivelist": "^9.0.2", "es6-promise": "^4.2.4", "express": "^4.16.3", diff --git a/packages/core/src/browser/credentials-service.ts b/packages/core/src/browser/credentials-service.ts new file mode 100644 index 0000000000000..bf351b878b7c2 --- /dev/null +++ b/packages/core/src/browser/credentials-service.ts @@ -0,0 +1,106 @@ +/******************************************************************************** + * Copyright (C) 2021 Red Hat, Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// code copied and modified from https://github.com/microsoft/vscode/blob/1.55.2/src/vs/workbench/services/credentials/common/credentials.ts#L12 + +import { inject, injectable } from 'inversify'; +import { Emitter, Event } from '../common/event'; +import { KeytarService } from '../common/keytar-protocol'; + +export interface CredentialsProvider { + getPassword(service: string, account: string): Promise; + setPassword(service: string, account: string, password: string): Promise; + deletePassword(service: string, account: string): Promise; + findPassword(service: string): Promise; + findCredentials(service: string): Promise>; +} + +export const CredentialsService = Symbol('CredentialsService'); + +export interface CredentialsService extends CredentialsProvider { + readonly onDidChangePassword: Event; +} + +export interface CredentialsChangeEvent { + service: string + account: string; +} + +@injectable() +export class CredentialsServiceImpl implements CredentialsService { + private onDidChangePasswordEmitter = new Emitter(); + readonly onDidChangePassword = this.onDidChangePasswordEmitter.event; + + private credentialsProvider: CredentialsProvider; + + constructor(@inject(KeytarService) private readonly keytarService: KeytarService) { + this.credentialsProvider = new KeytarCredentialsProvider(this.keytarService); + } + + getPassword(service: string, account: string): Promise { + return this.credentialsProvider.getPassword(service, account); + } + + async setPassword(service: string, account: string, password: string): Promise { + await this.credentialsProvider.setPassword(service, account, password); + + this.onDidChangePasswordEmitter.fire({ service, account }); + } + + deletePassword(service: string, account: string): Promise { + const didDelete = this.credentialsProvider.deletePassword(service, account); + this.onDidChangePasswordEmitter.fire({ service, account }); + + return didDelete; + } + + findPassword(service: string): Promise { + return this.credentialsProvider.findPassword(service); + } + + findCredentials(service: string): Promise> { + return this.credentialsProvider.findCredentials(service); + } +} + +class KeytarCredentialsProvider implements CredentialsProvider { + + constructor(private readonly keytarService: KeytarService) {} + + deletePassword(service: string, account: string): Promise { + return this.keytarService.deletePassword(service, account); + } + + findCredentials(service: string): Promise> { + return this.keytarService.findCredentials(service); + } + + findPassword(service: string): Promise { + return this.keytarService.findPassword(service); + } + + getPassword(service: string, account: string): Promise { + return this.keytarService.getPassword(service, account); + } + + setPassword(service: string, account: string, password: string): Promise { + return this.keytarService.setPassword(service, account, password); + } +} diff --git a/packages/core/src/browser/frontend-application-module.ts b/packages/core/src/browser/frontend-application-module.ts index 1658d8600cc41..4b7027b49879c 100644 --- a/packages/core/src/browser/frontend-application-module.ts +++ b/packages/core/src/browser/frontend-application-module.ts @@ -99,6 +99,8 @@ import { EncodingRegistry } from './encoding-registry'; import { EncodingService } from '../common/encoding-service'; import { AuthenticationService, AuthenticationServiceImpl } from '../browser/authentication-service'; import { DecorationsService, DecorationsServiceImpl } from './decorations-service'; +import { keytarServicePath, KeytarService } from '../common/keytar-protocol'; +import { CredentialsService, CredentialsServiceImpl } from './credentials-service'; export { bindResourceProvider, bindMessageService, bindPreferenceService }; @@ -344,4 +346,11 @@ export const frontendApplicationModule = new ContainerModule((bind, unbind, isBo bind(AuthenticationService).to(AuthenticationServiceImpl).inSingletonScope(); bind(DecorationsService).to(DecorationsServiceImpl).inSingletonScope(); + + bind(KeytarService).toDynamicValue(ctx => { + const connection = ctx.container.get(WebSocketConnectionProvider); + return connection.createProxy(keytarServicePath); + }).inSingletonScope(); + + bind(CredentialsService).to(CredentialsServiceImpl); }); diff --git a/packages/core/src/common/keytar-protocol.ts b/packages/core/src/common/keytar-protocol.ts new file mode 100644 index 0000000000000..f9dfbc11b533f --- /dev/null +++ b/packages/core/src/common/keytar-protocol.ts @@ -0,0 +1,26 @@ +/******************************************************************************** + * Copyright (C) 2021 Red Hat, Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +export const keytarServicePath = '/services/keytar'; + +export const KeytarService = Symbol('KeytarService'); +export interface KeytarService { + setPassword(service: string, account: string, password: string): Promise; + getPassword(service: string, account: string): Promise; + deletePassword(service: string, account: string): Promise; + findPassword(service: string): Promise; + findCredentials(service: string): Promise>; +} diff --git a/packages/core/src/node/backend-application-module.ts b/packages/core/src/node/backend-application-module.ts index 5443c6a08afce..bd65188c42053 100644 --- a/packages/core/src/node/backend-application-module.ts +++ b/packages/core/src/node/backend-application-module.ts @@ -30,6 +30,8 @@ import { EnvVariablesServerImpl } from './env-variables'; import { ConnectionContainerModule } from './messaging/connection-container-module'; import { QuickPickService, quickPickServicePath } from '../common/quick-pick-service'; import { WsRequestValidator, WsRequestValidatorContribution } from './ws-request-validators'; +import { KeytarService, keytarServicePath } from '../common/keytar-protocol'; +import { KeytarServiceImpl } from './keytar-server'; decorate(injectable(), ApplicationPackage); @@ -95,4 +97,8 @@ export const backendApplicationModule = new ContainerModule(bind => { bind(WsRequestValidator).toSelf().inSingletonScope(); bindContributionProvider(bind, WsRequestValidatorContribution); + bind(KeytarService).to(KeytarServiceImpl).inSingletonScope(); + bind(ConnectionHandler).toDynamicValue(ctx => + new JsonRpcConnectionHandler(keytarServicePath, () => ctx.container.get(KeytarService)) + ).inSingletonScope(); }); diff --git a/packages/core/src/node/keytar-server.ts b/packages/core/src/node/keytar-server.ts new file mode 100644 index 0000000000000..401f48b7ae7c4 --- /dev/null +++ b/packages/core/src/node/keytar-server.ts @@ -0,0 +1,98 @@ +/******************************************************************************** + * Copyright (C) 2021 Red Hat, Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// code copied and modified from https://github.com/microsoft/vscode/blob/1.55.2/src/vs/platform/native/electron-main/nativeHostMainService.ts#L679-L771 + +import { KeytarService } from '../common/keytar-protocol'; +import { injectable } from 'inversify'; +import { isWindows } from '../common'; +import * as keytar from 'keytar'; + +@injectable() +export class KeytarServiceImpl implements KeytarService { + private static readonly MAX_PASSWORD_LENGTH = 2500; + private static readonly PASSWORD_CHUNK_SIZE = KeytarServiceImpl.MAX_PASSWORD_LENGTH - 100; + + async setPassword(service: string, account: string, password: string): Promise { + if (isWindows && password.length > KeytarServiceImpl.MAX_PASSWORD_LENGTH) { + let index = 0; + let chunk = 0; + let hasNextChunk = true; + while (hasNextChunk) { + const passwordChunk = password.substring(index, index + KeytarServiceImpl.PASSWORD_CHUNK_SIZE); + index += KeytarServiceImpl.PASSWORD_CHUNK_SIZE; + hasNextChunk = password.length - index > 0; + + const content: ChunkedPassword = { + content: passwordChunk, + hasNextChunk: hasNextChunk + }; + + await keytar.setPassword(service, chunk ? `${account}-${chunk}` : account, JSON.stringify(content)); + chunk++; + } + + } else { + await keytar.setPassword(service, account, password); + } + } + + deletePassword(service: string, account: string): Promise { + return keytar.deletePassword(service, account); + } + + async getPassword(service: string, account: string): Promise { + const password = await keytar.getPassword(service, account); + if (password) { + try { + let { content, hasNextChunk }: ChunkedPassword = JSON.parse(password); + if (!content || !hasNextChunk) { + return password; + } + + let index = 1; + while (hasNextChunk) { + const nextChunk = await keytar.getPassword(service, `${account}-${index++}`); + const result: ChunkedPassword = JSON.parse(nextChunk!); + content += result.content; + hasNextChunk = result.hasNextChunk; + } + + return content; + } catch { + return password; + } + } + } + async findPassword(service: string): Promise { + const password = await keytar.findPassword(service); + if (password) { + return password; + } + } + async findCredentials(service: string): Promise> { + return keytar.findCredentials(service); + } +} + +interface ChunkedPassword { + content: string; + hasNextChunk: boolean; +} diff --git a/packages/plugin-ext/src/common/plugin-api-rpc.ts b/packages/plugin-ext/src/common/plugin-api-rpc.ts index 70a6b8c97c1d0..8d4df7a548aeb 100644 --- a/packages/plugin-ext/src/common/plugin-api-rpc.ts +++ b/packages/plugin-ext/src/common/plugin-api-rpc.ts @@ -1716,6 +1716,7 @@ export const PLUGIN_RPC_CONTEXT = { DEBUG_MAIN: createProxyIdentifier('DebugMain'), FILE_SYSTEM_MAIN: createProxyIdentifier('FileSystemMain'), SCM_MAIN: createProxyIdentifier('ScmMain'), + SECRETS_MAIN: createProxyIdentifier('SecretsMain'), DECORATIONS_MAIN: createProxyIdentifier('DecorationsMain'), WINDOW_MAIN: createProxyIdentifier('WindowMain'), CLIPBOARD_MAIN: >createProxyIdentifier('ClipboardMain'), @@ -1750,6 +1751,7 @@ export const MAIN_RPC_CONTEXT = { FILE_SYSTEM_EXT: createProxyIdentifier('FileSystemExt'), ExtHostFileSystemEventService: createProxyIdentifier('ExtHostFileSystemEventService'), SCM_EXT: createProxyIdentifier('ScmExt'), + SECRETS_EXT: createProxyIdentifier('SecretsExt'), DECORATIONS_EXT: createProxyIdentifier('DecorationsExt'), LABEL_SERVICE_EXT: createProxyIdentifier('LabelServiceExt'), TIMELINE_EXT: createProxyIdentifier('TimeLineExt'), @@ -1807,3 +1809,13 @@ export interface LabelServiceMain { $registerResourceLabelFormatter(handle: number, formatter: ResourceLabelFormatter): void; $unregisterResourceLabelFormatter(handle: number): void; } + +export interface SecretsExt { + $onDidChangePassword(e: { extensionId: string, key: string }): Promise; +} + +export interface SecretsMain { + $getPassword(extensionId: string, key: string): Promise; + $setPassword(extensionId: string, key: string, value: string): Promise; + $deletePassword(extensionId: string, key: string): Promise; +} diff --git a/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts b/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts index 0dbd0ef94b54b..4d1ac434b2fac 100644 --- a/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts +++ b/packages/plugin-ext/src/hosted/browser/worker/worker-main.ts @@ -34,6 +34,7 @@ import { WebviewsExtImpl } from '../../../plugin/webviews'; import { loadManifest } from './plugin-manifest-loader'; import { TerminalServiceExtImpl } from '../../../plugin/terminal-ext'; import { reviver } from '../../../plugin/types-impl'; +import { SecretsExtImpl } from '../../../plugin/secrets-ext'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const ctx = self as any; @@ -68,6 +69,7 @@ const preferenceRegistryExt = new PreferenceRegistryExtImpl(rpc, workspaceExt); const debugExt = createDebugExtStub(rpc); const clipboardExt = new ClipboardExt(rpc); const webviewExt = new WebviewsExtImpl(rpc, workspaceExt); +const secretsExt = new SecretsExtImpl(rpc); const terminalService: TerminalServiceExt = new TerminalServiceExtImpl(rpc); const pluginManager = new PluginManagerExtImpl({ @@ -155,7 +157,7 @@ const pluginManager = new PluginManagerExtImpl({ } } } -}, envExt, terminalService, storageProxy, preferenceRegistryExt, webviewExt, rpc); +}, envExt, terminalService, storageProxy, secretsExt, preferenceRegistryExt, webviewExt, rpc); const apiFactory = createAPIFactory( rpc, diff --git a/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts b/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts index baa668f94361c..df726044ef482 100644 --- a/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts +++ b/packages/plugin-ext/src/hosted/node/plugin-host-rpc.ts @@ -31,6 +31,7 @@ import { loadManifest } from './plugin-manifest-loader'; import { KeyValueStorageProxy } from '../../plugin/plugin-storage'; import { WebviewsExtImpl } from '../../plugin/webviews'; import { TerminalServiceExtImpl } from '../../plugin/terminal-ext'; +import { SecretsExtImpl } from '../../plugin/secrets-ext'; /** * Handle the RPC calls. @@ -56,13 +57,15 @@ export class PluginHostRPC { const clipboardExt = new ClipboardExt(this.rpc); const webviewExt = new WebviewsExtImpl(this.rpc, workspaceExt); const terminalService = new TerminalServiceExtImpl(this.rpc); - this.pluginManager = this.createPluginManager(envExt, terminalService, storageProxy, preferenceRegistryExt, webviewExt, this.rpc); + const secretsExt = new SecretsExtImpl(this.rpc); + this.pluginManager = this.createPluginManager(envExt, terminalService, storageProxy, preferenceRegistryExt, webviewExt, secretsExt, this.rpc); this.rpc.set(MAIN_RPC_CONTEXT.HOSTED_PLUGIN_MANAGER_EXT, this.pluginManager); this.rpc.set(MAIN_RPC_CONTEXT.EDITORS_AND_DOCUMENTS_EXT, editorsAndDocumentsExt); this.rpc.set(MAIN_RPC_CONTEXT.WORKSPACE_EXT, workspaceExt); this.rpc.set(MAIN_RPC_CONTEXT.PREFERENCE_REGISTRY_EXT, preferenceRegistryExt); this.rpc.set(MAIN_RPC_CONTEXT.STORAGE_EXT, storageProxy); this.rpc.set(MAIN_RPC_CONTEXT.WEBVIEWS_EXT, webviewExt); + this.rpc.set(MAIN_RPC_CONTEXT.SECRETS_EXT, secretsExt); this.apiFactory = createAPIFactory( this.rpc, @@ -95,7 +98,8 @@ export class PluginHostRPC { } createPluginManager( - envExt: EnvExtImpl, terminalService: TerminalServiceExtImpl, storageProxy: KeyValueStorageProxy, preferencesManager: PreferenceRegistryExtImpl, webview: WebviewsExtImpl, + envExt: EnvExtImpl, terminalService: TerminalServiceExtImpl, storageProxy: KeyValueStorageProxy, + preferencesManager: PreferenceRegistryExtImpl, webview: WebviewsExtImpl, secretsExt: SecretsExtImpl, // eslint-disable-next-line @typescript-eslint/no-explicit-any rpc: any): PluginManagerExtImpl { const { extensionTestsPath } = process.env; @@ -229,7 +233,7 @@ export class PluginHostRPC { `Path ${extensionTestsPath} does not point to a valid extension test runner.` ); } : undefined - }, envExt, terminalService, storageProxy, preferencesManager, webview, rpc); + }, envExt, terminalService, storageProxy, secretsExt, preferencesManager, webview, rpc); return pluginManager; } } diff --git a/packages/plugin-ext/src/main/browser/main-context.ts b/packages/plugin-ext/src/main/browser/main-context.ts index f0592b0df5c04..3314e8a39e455 100644 --- a/packages/plugin-ext/src/main/browser/main-context.ts +++ b/packages/plugin-ext/src/main/browser/main-context.ts @@ -55,6 +55,7 @@ import { AuthenticationMainImpl } from './authentication-main'; import { ThemingMainImpl } from './theming-main'; import { CommentsMainImp } from './comments/comments-main'; import { CustomEditorsMainImpl } from './custom-editors/custom-editors-main'; +import { SecretsMainImpl } from './secrets-main'; export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container): void { const authenticationMain = new AuthenticationMainImpl(rpc, container); @@ -151,6 +152,9 @@ export function setUpPluginApi(rpc: RPCProtocol, container: interfaces.Container const scmMain = new ScmMainImpl(rpc, container); rpc.set(PLUGIN_RPC_CONTEXT.SCM_MAIN, scmMain); + const secretsMain = new SecretsMainImpl(rpc, container); + rpc.set(PLUGIN_RPC_CONTEXT.SECRETS_MAIN, secretsMain); + const decorationsMain = new DecorationsMainImpl(rpc, container); rpc.set(PLUGIN_RPC_CONTEXT.DECORATIONS_MAIN, decorationsMain); diff --git a/packages/plugin-ext/src/main/browser/secrets-main.ts b/packages/plugin-ext/src/main/browser/secrets-main.ts new file mode 100644 index 0000000000000..1335ed5d1807f --- /dev/null +++ b/packages/plugin-ext/src/main/browser/secrets-main.ts @@ -0,0 +1,82 @@ +/******************************************************************************** + * Copyright (C) 2021 Red Hat, Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// code copied and modified from https://github.com/microsoft/vscode/blob/1.55.2/src/vs/workbench/api/browser/mainThreadSecretState.ts + +import { SecretsExt, SecretsMain } from '../../common/plugin-api-rpc'; +import { RPCProtocol } from '../../common/rpc-protocol'; +import { interfaces } from '@theia/core/shared/inversify'; +import { MAIN_RPC_CONTEXT } from '../../common'; +import { CredentialsService } from '@theia/core/lib/browser/credentials-service'; + +export class SecretsMainImpl implements SecretsMain { + + private readonly proxy: SecretsExt; + private readonly credentialsService: CredentialsService; + + constructor(rpc: RPCProtocol, container: interfaces.Container) { + this.proxy = rpc.getProxy(MAIN_RPC_CONTEXT.SECRETS_EXT); + this.credentialsService = container.get(CredentialsService); + this.credentialsService.onDidChangePassword(e => { + const extensionId = e.service.substring(window.location.hostname.length + 1); + this.proxy.$onDidChangePassword({ extensionId, key: e.account }); + }); + } + + private static getFullKey(extensionId: string): string { + return `${window.location.hostname}-${extensionId}`; + } + + async $getPassword(extensionId: string, key: string): Promise { + const fullKey = SecretsMainImpl.getFullKey(extensionId); + const passwordData = await this.credentialsService.getPassword(fullKey, key); + + if (passwordData) { + try { + const data = JSON.parse(passwordData); + if (data.extensionId === extensionId) { + return data.content; + } + } catch (e) { + throw new Error('Cannot get password'); + } + } + + return undefined; + } + + async $setPassword(extensionId: string, key: string, value: string): Promise { + const fullKey = SecretsMainImpl.getFullKey(extensionId); + const passwordData = JSON.stringify({ + extensionId, + content: value + }); + return this.credentialsService.setPassword(fullKey, key, passwordData); + } + + async $deletePassword(extensionId: string, key: string): Promise { + try { + const fullKey = SecretsMainImpl.getFullKey(extensionId); + await this.credentialsService.deletePassword(fullKey, key); + } catch (e) { + throw new Error('Cannot delete password'); + } + } +} diff --git a/packages/plugin-ext/src/plugin/plugin-manager.ts b/packages/plugin-ext/src/plugin/plugin-manager.ts index 5a1f2fb9dc323..7d13e4e4120ba 100644 --- a/packages/plugin-ext/src/plugin/plugin-manager.ts +++ b/packages/plugin-ext/src/plugin/plugin-manager.ts @@ -39,6 +39,7 @@ import { RPCProtocol } from '../common/rpc-protocol'; import { Emitter } from '@theia/core/lib/common/event'; import { WebviewsExtImpl } from './webviews'; import { URI as Uri } from './types-impl'; +import { SecretsExtImpl, SecretStorageExt } from '../plugin/secrets-ext'; export interface PluginHost { @@ -110,6 +111,7 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { private readonly envExt: EnvExtImpl, private readonly terminalService: TerminalServiceExt, private readonly storageProxy: KeyValueStorageProxy, + private readonly secrets: SecretsExtImpl, private readonly preferencesManager: PreferenceRegistryExtImpl, private readonly webview: WebviewsExtImpl, private readonly rpc: RPCProtocol @@ -344,6 +346,7 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { const asAbsolutePath = (relativePath: string): string => join(plugin.pluginFolder, relativePath); const logPath = join(configStorage.hostLogPath, plugin.model.id); // todo check format const storagePath = configStorage.hostStoragePath ? join(configStorage.hostStoragePath, plugin.model.id) : undefined; + const secrets = new SecretStorageExt(plugin, this.secrets); const globalStoragePath = join(configStorage.hostGlobalStoragePath, plugin.model.id); const pluginContext: theia.PluginContext = { extensionPath: plugin.pluginFolder, @@ -355,6 +358,7 @@ export class PluginManagerExtImpl implements PluginManagerExt, PluginManager { logPath: logPath, storagePath: storagePath, storageUri: storagePath ? Uri.file(storagePath) : undefined, + secrets, globalStoragePath: globalStoragePath, globalStorageUri: Uri.file(globalStoragePath), environmentVariableCollection: this.terminalService.getEnvironmentVariableCollection(plugin.model.id) diff --git a/packages/plugin-ext/src/plugin/secrets-ext.ts b/packages/plugin-ext/src/plugin/secrets-ext.ts new file mode 100644 index 0000000000000..b15be1e99d0cf --- /dev/null +++ b/packages/plugin-ext/src/plugin/secrets-ext.ts @@ -0,0 +1,84 @@ +/******************************************************************************** + * Copyright (C) 2021 Red Hat, Inc. and others. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0 + ********************************************************************************/ + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +// code copied and modified from https://github.com/microsoft/vscode/blob/1.55.2/src/vs/workbench/api/common/extHostSecrets.ts + +import { Plugin, PLUGIN_RPC_CONTEXT, SecretsExt, SecretsMain } from '../common/plugin-api-rpc'; +import { RPCProtocol } from '../common/rpc-protocol'; +import { Event, Emitter } from '@theia/core/lib/common/event'; +import * as theia from '@theia/plugin'; + +export class SecretsExtImpl implements SecretsExt { + private proxy: SecretsMain; + private onDidChangePasswordEmitter = new Emitter<{ extensionId: string, key: string }>(); + readonly onDidChangePassword = this.onDidChangePasswordEmitter.event; + + constructor(rpc: RPCProtocol) { + this.proxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.SECRETS_MAIN); + } + + async $onDidChangePassword(e: { extensionId: string, key: string }): Promise { + this.onDidChangePasswordEmitter.fire(e); + } + + get(extensionId: string, key: string): Promise { + return this.proxy.$getPassword(extensionId, key); + } + + store(extensionId: string, key: string, value: string): Promise { + return this.proxy.$setPassword(extensionId, key, value); + } + + delete(extensionId: string, key: string): Promise { + return this.proxy.$deletePassword(extensionId, key); + } +} + +export class SecretStorageExt implements theia.SecretStorage { + + protected readonly id: string; + readonly secretState: SecretsExtImpl; + + private onDidChangeEmitter = new Emitter(); + readonly onDidChange: Event = this.onDidChangeEmitter.event; + + constructor(pluginDescription: Plugin, secretState: SecretsExtImpl) { + this.id = pluginDescription.model.id.toLowerCase(); + this.secretState = secretState; + + this.secretState.onDidChangePassword(e => { + if (e.extensionId === this.id) { + this.onDidChangeEmitter.fire({ key: e.key }); + } + }); + } + + get(key: string): Promise { + return this.secretState.get(this.id, key); + } + + store(key: string, value: string): Promise { + return this.secretState.store(this.id, key, value); + } + + delete(key: string): Promise { + return this.secretState.delete(this.id, key); + } +} diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts index 333df0ad3ffc4..61e644b94653c 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -3174,6 +3174,11 @@ declare module '@theia/plugin' { */ globalState: Memento; + /** + * A storage utility for secrets. + */ + readonly secrets: SecretStorage; + /** * The absolute file path of the directory containing the extension. */ @@ -3288,6 +3293,48 @@ declare module '@theia/plugin' { update(key: string, value: any): PromiseLike; } + /** + * The event data that is fired when a secret is added or removed. + */ + export interface SecretStorageChangeEvent { + /** + * The key of the secret that has changed. + */ + readonly key: string; + } + + /** + * Represents a storage utility for secrets, information that is + * sensitive. + */ + export interface SecretStorage { + /** + * Retrieve a secret that was stored with key. Returns undefined if there + * is no password matching that key. + * @param key The key the secret was stored under. + * @returns The stored value or `undefined`. + */ + get(key: string): Thenable; + + /** + * Store a secret under a given key. + * @param key The key to store the secret under. + * @param value The secret. + */ + store(key: string, value: string): Thenable; + + /** + * Remove a secret from storage. + * @param key The key the secret was stored under. + */ + delete(key: string): Thenable; + + /** + * Fires when a secret is stored or deleted. + */ + onDidChange: Event; + } + /** * Defines a port mapping used for localhost inside the webview. */ diff --git a/yarn.lock b/yarn.lock index a89afab0547cc..9fa6e05511328 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6346,6 +6346,14 @@ jxLoader@*: promised-io "*" walker "1.x" +keytar@7.7.0: + version "7.7.0" + resolved "https://registry.yarnpkg.com/keytar/-/keytar-7.7.0.tgz#3002b106c01631aa79b1aa9ee0493b94179bbbd2" + integrity sha512-YEY9HWqThQc5q5xbXbRwsZTh2PJ36OSYRjSv3NN2xf5s5dpLTjEZnC2YikR29OaVybf9nQ0dJ/80i40RS97t/A== + dependencies: + node-addon-api "^3.0.0" + prebuild-install "^6.0.0" + keyv@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9" @@ -7198,14 +7206,14 @@ nise@^1.0.1: lolex "^5.0.1" path-to-regexp "^1.7.0" -node-abi@^2.11.0, node-abi@^2.18.0, node-abi@^2.7.0: +node-abi@^2.11.0, node-abi@^2.18.0, node-abi@^2.21.0, node-abi@^2.7.0: version "2.30.0" resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-2.30.0.tgz#8be53bf3e7945a34eea10e0fc9a5982776cf550b" integrity sha512-g6bZh3YCKQRdwuO/tSZZYJAw622SjsRfJ2X0Iy4sSOHZ34/sPPdVBn8fev2tj7njzLwuqPw9uMtGsGkO5kIQvg== dependencies: semver "^5.4.1" -node-addon-api@*: +node-addon-api@*, node-addon-api@^3.0.0: version "3.2.1" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== @@ -8292,6 +8300,25 @@ prebuild-install@^5.2.4: tunnel-agent "^0.6.0" which-pm-runs "^1.0.0" +prebuild-install@^6.0.0: + version "6.1.3" + resolved "https://registry.yarnpkg.com/prebuild-install/-/prebuild-install-6.1.3.tgz#8ea1f9d7386a0b30f7ef20247e36f8b2b82825a2" + integrity sha512-iqqSR84tNYQUQHRXalSKdIaM8Ov1QxOVuBNWI7+BzZWv6Ih9k75wOnH1rGQ9WWTaaLkTpxWKIciOF0KyfM74+Q== + dependencies: + detect-libc "^1.0.3" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^1.0.1" + node-abi "^2.21.0" + npmlog "^4.0.1" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^3.0.3" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"