From f4adefdbe3772f4674771c2fe86ac2b362aa701a Mon Sep 17 00:00:00 2001 From: Brian Ingles Date: Tue, 15 Oct 2024 17:08:15 -0500 Subject: [PATCH] First pass storing private key (#32) --- package.json | 14 +++++++ src/common/commands.ts | 45 ++++++++++++-------- src/controllers/ExtensionController.ts | 14 ++++++- src/controllers/UserLoginController.ts | 57 ++++++++++++++++++++++++-- src/services/SecretService.ts | 39 ++++++++++++++++++ src/services/index.ts | 1 + src/types/commonTypes.d.ts | 7 ++++ src/util/authUtils.ts | 45 ++++++++++++++++++++ src/util/index.ts | 1 + 9 files changed, 203 insertions(+), 20 deletions(-) create mode 100644 src/services/SecretService.ts create mode 100644 src/util/authUtils.ts diff --git a/package.json b/package.json index 1e88a5b6..88f7aa6d 100644 --- a/package.json +++ b/package.json @@ -180,6 +180,11 @@ "title": "Deephaven: Discard Connection", "icon": "$(trash)" }, + { + "command": "vscode-deephaven.generateDHEKeyPair", + "title": "Deephaven: Generate DHE Key Pair", + "icon": "$(key)" + }, { "command": "vscode-deephaven.openInBrowser", "title": "Deephaven: Open in Browser", @@ -656,6 +661,10 @@ "command": "vscode-deephaven.disconnectFromServer", "when": "false" }, + { + "command": "vscode-deephaven.generateDHEKeyPair", + "when:": "false" + }, { "command": "vscode-deephaven.openVariablePanels", "when": "false" @@ -734,6 +743,11 @@ "when": "view == vscode-deephaven.serverConnectionTree && viewItem == isConnection", "group": "inline@2" }, + { + "command": "vscode-deephaven.generateDHEKeyPair", + "when": "view == vscode-deephaven.serverTree && viewItem == isDHEServerRunning", + "group": "inline" + }, { "command": "vscode-deephaven.openInBrowser", "when": "view == vscode-deephaven.serverTree && (viewItem == isManagedServerConnected || viewItem == isServerRunningConnected || viewItem == isServerRunningDisconnected || viewItem == isManagedServerDisconnected || viewItem == isDHEServerRunning)", diff --git a/src/common/commands.ts b/src/common/commands.ts index 06541360..273e152b 100644 --- a/src/common/commands.ts +++ b/src/common/commands.ts @@ -1,18 +1,31 @@ import { EXTENSION_ID } from './constants'; -export const CONNECT_TO_SERVER_CMD = `${EXTENSION_ID}.connectToServer`; -export const CREATE_NEW_TEXT_DOC_CMD = `${EXTENSION_ID}.createNewTextDoc`; -export const DISCONNECT_EDITOR_CMD = `${EXTENSION_ID}.disconnectEditor`; -export const DISCONNECT_FROM_SERVER_CMD = `${EXTENSION_ID}.disconnectFromServer`; -export const DOWNLOAD_LOGS_CMD = `${EXTENSION_ID}.downloadLogs`; -export const OPEN_IN_BROWSER_CMD = `${EXTENSION_ID}.openInBrowser`; -export const OPEN_VARIABLE_PANELS_CMD = `${EXTENSION_ID}.openVariablePanels`; -export const REFRESH_SERVER_TREE_CMD = `${EXTENSION_ID}.refreshServerTree`; -export const REFRESH_SERVER_CONNECTION_TREE_CMD = `${EXTENSION_ID}.refreshServerConnectionTree`; -export const REFRESH_VARIABLE_PANELS_CMD = `${EXTENSION_ID}.refreshVariablePanels`; -export const REQUEST_DHE_USER_CREDENTIALS_CMD = `${EXTENSION_ID}.requestDheUserCredentials`; -export const RUN_CODE_COMMAND = `${EXTENSION_ID}.runCode`; -export const RUN_SELECTION_COMMAND = `${EXTENSION_ID}.runSelection`; -export const SELECT_CONNECTION_COMMAND = `${EXTENSION_ID}.selectConnection`; -export const START_SERVER_CMD = `${EXTENSION_ID}.startServer`; -export const STOP_SERVER_CMD = `${EXTENSION_ID}.stopServer`; +/** + * Create a command string prefixed with the extension id. + * @param cmd The command string suffix. + */ +function cmd(cmd: T): `${typeof EXTENSION_ID}.${T}` { + return `${EXTENSION_ID}.${cmd}`; +} + +export const CONNECT_TO_SERVER_CMD = cmd('connectToServer'); +export const CREATE_NEW_TEXT_DOC_CMD = cmd('createNewTextDoc'); +export const DISCONNECT_EDITOR_CMD = cmd('disconnectEditor'); +export const DISCONNECT_FROM_SERVER_CMD = cmd('disconnectFromServer'); +export const DOWNLOAD_LOGS_CMD = cmd('downloadLogs'); +export const GENERATE_DHE_KEY_PAIR_CMD = cmd('generateDHEKeyPair'); +export const OPEN_IN_BROWSER_CMD = cmd('openInBrowser'); +export const OPEN_VARIABLE_PANELS_CMD = cmd('openVariablePanels'); +export const REFRESH_SERVER_TREE_CMD = cmd('refreshServerTree'); +export const REFRESH_SERVER_CONNECTION_TREE_CMD = cmd( + 'refreshServerConnectionTree' +); +export const REFRESH_VARIABLE_PANELS_CMD = cmd('refreshVariablePanels'); +export const REQUEST_DHE_USER_CREDENTIALS_CMD = cmd( + 'requestDheUserCredentials' +); +export const RUN_CODE_COMMAND = cmd('runCode'); +export const RUN_SELECTION_COMMAND = cmd('runSelection'); +export const SELECT_CONNECTION_COMMAND = cmd('selectConnection'); +export const START_SERVER_CMD = cmd('startServer'); +export const STOP_SERVER_CMD = cmd('stopServer'); diff --git a/src/controllers/ExtensionController.ts b/src/controllers/ExtensionController.ts index 62065670..006df905 100644 --- a/src/controllers/ExtensionController.ts +++ b/src/controllers/ExtensionController.ts @@ -47,6 +47,7 @@ import { DheServiceCache, DhService, PanelService, + SecretService, ServerManager, URLMap, } from '../services'; @@ -84,6 +85,7 @@ export class ExtensionController implements Disposable { this.initializeDiagnostics(); this.initializeConfig(); + this.initializeSecrets(); this.initializeCodeLenses(); this.initializeHoverProviders(); this.initializeMessaging(); @@ -120,6 +122,7 @@ export class ExtensionController implements Disposable { private _dhcServiceFactory: IDhServiceFactory | null = null; private _dheJsApiCache: IAsyncCacheService | null = null; private _dheServiceFactory: IDheServiceFactory | null = null; + private _secretService: SecretService | null = null; private _serverManager: IServerManager | null = null; private _userLoginController: UserLoginController | null = null; @@ -168,6 +171,13 @@ export class ExtensionController implements Disposable { ); }; + /** + * Initialize secrets. + */ + initializeSecrets = (): void => { + this._secretService = new SecretService(this._context.secrets); + }; + /** * Initialize connection controller. */ @@ -224,9 +234,11 @@ export class ExtensionController implements Disposable { */ initializeUserLoginController = (): void => { assertDefined(this._dheCredentialsCache, 'dheCredentialsCache'); + assertDefined(this._secretService, 'secretService'); this._userLoginController = new UserLoginController( - this._dheCredentialsCache + this._dheCredentialsCache, + this._secretService ); this._context.subscriptions.push(this._userLoginController); diff --git a/src/controllers/UserLoginController.ts b/src/controllers/UserLoginController.ts index 52202abc..7f232a23 100644 --- a/src/controllers/UserLoginController.ts +++ b/src/controllers/UserLoginController.ts @@ -1,16 +1,32 @@ import * as vscode from 'vscode'; -import type { URLMap } from '../services'; +import type { SecretService, URLMap } from '../services'; import { ControllerBase } from './ControllerBase'; import type { LoginCredentials as DheLoginCredentials } from '@deephaven-enterprise/jsapi-types'; -import { REQUEST_DHE_USER_CREDENTIALS_CMD } from '../common'; +import { + GENERATE_DHE_KEY_PAIR_CMD, + REQUEST_DHE_USER_CREDENTIALS_CMD, +} from '../common'; +import { generateKeyPairForUser, Logger } from '../util'; +import type { ServerState } from '../types'; + +const logger = new Logger('UserLoginController'); /** * Controller for user login. */ export class UserLoginController extends ControllerBase { - constructor(dheCredentialsCache: URLMap) { + constructor( + dheCredentialsCache: URLMap, + secretService: SecretService + ) { super(); this.dheCredentialsCache = dheCredentialsCache; + this.secretService = secretService; + + this.registerCommand( + GENERATE_DHE_KEY_PAIR_CMD, + this.onDidRequestGenerateDheKeyPair + ); this.registerCommand( REQUEST_DHE_USER_CREDENTIALS_CMD, @@ -19,6 +35,41 @@ export class UserLoginController extends ControllerBase { } private readonly dheCredentialsCache: URLMap; + private readonly secretService: SecretService; + + /** + * Handle request for generating a DHE key pair. + * @param serverState The server state to generate the key pair for. + */ + onDidRequestGenerateDheKeyPair = async ( + serverState: ServerState + ): Promise => { + const serverUrl = serverState.url; + await this.onDidRequestDheUserCredentials(serverUrl); + + const credentials = this.dheCredentialsCache.get(serverUrl); + if (credentials?.username == null) { + return; + } + + const { publicKey, privateKey } = generateKeyPairForUser( + credentials.username + ); + + // TODO: Challenge response send public key to server + logger.debug('Public key:', publicKey); + + await this.secretService.storePrivateKey( + credentials.username, + serverUrl, + privateKey + ); + + // Remove credentials from cache since presumably a valid key pair was + // generated and we'll want the user to authenticate with the private key + // instead. + this.dheCredentialsCache.delete(serverUrl); + }; /** * Handle the request for DHE user credentials. If credentials are provided, diff --git a/src/services/SecretService.ts b/src/services/SecretService.ts new file mode 100644 index 00000000..54829075 --- /dev/null +++ b/src/services/SecretService.ts @@ -0,0 +1,39 @@ +import type { SecretStorage } from 'vscode'; +import type { DHPrivateKey } from '../types'; + +class Key { + static privateKey(userName: string, serverUrl: URL): string { + return `privateKey.${userName}.${serverUrl.toString()}`; + } +} + +/** + * Wrapper around `vscode.SecretStorage` for storing and retrieving secrets. + * NOTE: For debugging, the secret store contents can be dumped to devtools + * console via: + * > Developer: Log Storage Database Contents + */ +export class SecretService { + constructor(secrets: SecretStorage) { + this._secrets = secrets; + } + + private readonly _secrets: SecretStorage; + + getPrivateKey = async ( + userName: string, + serverUrl: URL + ): Promise => { + return this._secrets.get(Key.privateKey(userName, serverUrl)) as Promise< + DHPrivateKey | undefined + >; + }; + + storePrivateKey = async ( + userName: string, + serverUrl: URL, + privateKey: DHPrivateKey + ): Promise => { + await this._secrets.store(Key.privateKey(userName, serverUrl), privateKey); + }; +} diff --git a/src/services/index.ts b/src/services/index.ts index 0971c9a3..e161de37 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -6,6 +6,7 @@ export * from './DhcServiceFactory'; export * from './DheService'; export * from './PanelService'; export * from './PollingService'; +export * from './SecretService'; export * from './SerializedKeyMap'; export * from './ServerManager'; export * from './URIMap'; diff --git a/src/types/commonTypes.d.ts b/src/types/commonTypes.d.ts index 77347026..83d1bdba 100644 --- a/src/types/commonTypes.d.ts +++ b/src/types/commonTypes.d.ts @@ -37,6 +37,13 @@ export interface EnterpriseConnectionConfig { experimentalWorkerConfig?: WorkerConfig; } +export type DHPrivateKey = Brand<'DHPrivateKey', string>; +export type DHPublicKey = Brand<'DHPublicKey', string>; +export type DHKeyPair = { + privateKey: DHPrivateKey; + publicKey: DHPublicKey; +}; + export type ServerConnectionConfig = | CoreConnectionConfig | EnterpriseConnectionConfig diff --git a/src/util/authUtils.ts b/src/util/authUtils.ts new file mode 100644 index 00000000..b1d3edad --- /dev/null +++ b/src/util/authUtils.ts @@ -0,0 +1,45 @@ +import { generateKeyPairSync } from 'node:crypto'; +import type { DHKeyPair, DHPrivateKey, DHPublicKey } from '../types'; + +// synonymous with secp256r1 +const NAMED_CURVE = 'prime256v1' as const; + +/** + * Generate a new keypair in Deephaven format. + * @param userName The userName to generate the keypair for. + */ +export function generateKeyPairForUser( + userName: TUser +): DHKeyPair { + const { publicKey: publicKeyBuffer, privateKey: privateKeyBuffer } = + generateKeyPairSync('ec', { + namedCurve: NAMED_CURVE, + publicKeyEncoding: { type: 'spki', format: 'der' }, + privateKeyEncoding: { type: 'pkcs8', format: 'der' }, + }); + + const publicBase64Str = toEcBase64String(publicKeyBuffer); + const privateBase64Str = toEcBase64String(privateKeyBuffer); + + const publicKey = `${userName} ${publicBase64Str}` as DHPublicKey; + const privateKey = [ + `user ${userName}`, + `operateas ${userName}`, + `public ${publicBase64Str}`, + `private ${privateBase64Str}`, + ].join('\n') as DHPrivateKey; + + return { + publicKey, + privateKey, + }; +} + +/** + * Prepend 'EC:' to the key and convert to a base64 string. + * @param keyBuffer + */ +export function toEcBase64String(keyBuffer: Buffer): string { + const ecSentinel = Buffer.from('EC:'); + return Buffer.concat([ecSentinel, keyBuffer]).toString('base64'); +} diff --git a/src/util/index.ts b/src/util/index.ts index e9389a37..81ea8f33 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,4 +1,5 @@ export * from './assertUtil'; +export * from './authUtils'; export * from './dataUtils'; export * from './idUtils'; export * from './isInstanceOf';