diff --git a/package.json b/package.json index b18c6dc863a5..7e8c61c59557 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ ] }, "dependencies": { + "@aws-crypto/client-node": "^3.1.1", "@elastic/datemath": "5.0.3", "@elastic/eui": "34.6.0", "@elastic/good": "^9.0.1-kibana3", diff --git a/src/plugins/credential_management/public/components/create_credential_wizard/create_credential_wizard.tsx b/src/plugins/credential_management/public/components/create_credential_wizard/create_credential_wizard.tsx index e60a470ff0f2..19770188dd6a 100644 --- a/src/plugins/credential_management/public/components/create_credential_wizard/create_credential_wizard.tsx +++ b/src/plugins/credential_management/public/components/create_credential_wizard/create_credential_wizard.tsx @@ -30,7 +30,7 @@ interface CreateCredentialWizardState { credentialName: string; authType: string; credentialMaterialsType: string; - userName: string; + username: string; password: string; dual: boolean; toasts: EuiGlobalToastListToast[]; @@ -51,8 +51,8 @@ export class CreateCredentialWizard extends React.Component< this.state = { credentialName: '', authType: 'shared', - credentialMaterialsType: 'username_password_credential', - userName: '', + credentialMaterialsType: 'username_password', + username: '', password: '', dual: true, toasts: [], @@ -71,7 +71,7 @@ export class CreateCredentialWizard extends React.Component< const header = this.renderHeader(); const options = [ - { value: 'username_password_credential', text: 'Username and Password Credential' }, + { value: 'username_password', text: 'Username and Password Credential' }, { value: 'no_auth', text: 'No Auth' }, ]; @@ -127,8 +127,8 @@ export class CreateCredentialWizard extends React.Component< this.setState({ userName: e.target.value })} + value={this.state.username || ''} + onChange={(e) => this.setState({ username: e.target.value })} /> @@ -181,7 +181,7 @@ export class CreateCredentialWizard extends React.Component< credentialMaterials: { credentialMaterialsType: this.state.credentialMaterialsType, credentialMaterialsContent: { - userName: this.state.userName, + username: this.state.username, password: this.state.password, }, }, diff --git a/src/plugins/data_source/common/credentials/index.ts b/src/plugins/data_source/common/credentials/index.ts new file mode 100644 index 000000000000..70bfde226f9f --- /dev/null +++ b/src/plugins/data_source/common/credentials/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * as Credential from './types'; diff --git a/src/plugins/data_source/common/credentials/types.ts b/src/plugins/data_source/common/credentials/types.ts new file mode 100644 index 000000000000..a774f515a03a --- /dev/null +++ b/src/plugins/data_source/common/credentials/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectAttributes } from 'src/core/types'; + +export type SharedAuthType = 'shared'; +export type UsernamePasswordType = 'username_password'; +export type NoAuthType = 'no_auth'; + +export interface CredentialSavedObjectAttributes extends SavedObjectAttributes { + title: string; + authType: SharedAuthType; + credentialMaterials: CredentialMaterials; + description?: string; +} + +export interface CredentialMaterials extends SavedObjectAttributes { + credentialMaterialsType: UsernamePasswordType | NoAuthType; + credentialMaterialsContent?: UsernamePasswordTypedContent; +} + +export interface UsernamePasswordTypedContent extends SavedObjectAttributes { + username: string; + password: string; +} diff --git a/src/plugins/data_source/common/index.ts b/src/plugins/data_source/common/index.ts index 10fde727a706..bf5c6b1b0197 100644 --- a/src/plugins/data_source/common/index.ts +++ b/src/plugins/data_source/common/index.ts @@ -5,3 +5,5 @@ export const PLUGIN_ID = 'dataSource'; export const PLUGIN_NAME = 'data_source'; + +export { Credential } from './credentials'; diff --git a/src/plugins/data_source/config.ts b/src/plugins/data_source/config.ts new file mode 100644 index 000000000000..032f2c5a915a --- /dev/null +++ b/src/plugins/data_source/config.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema, TypeOf } from '@osd/config-schema'; + +const KEY_NAME_MIN_LENGTH: number = 1; +const KEY_NAME_MAX_LENGTH: number = 100; +// Wrapping key size shoule be 32 bytes, as used in envelope encryption algorithms. +const WRAPPING_KEY_SIZE: number = 32; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: false }), + wrappingKeyName: schema.string({ + minLength: KEY_NAME_MIN_LENGTH, + maxLength: KEY_NAME_MAX_LENGTH, + defaultValue: 'wrappingKeyName', + }), + wrappingKeyNamespace: schema.string({ + minLength: KEY_NAME_MIN_LENGTH, + maxLength: KEY_NAME_MAX_LENGTH, + defaultValue: 'wrappingKeyNamespace', + }), + wrappingKey: schema.arrayOf(schema.number(), { + minSize: WRAPPING_KEY_SIZE, + maxSize: WRAPPING_KEY_SIZE, + }), +}); + +export type DataSourcePluginConfigType = TypeOf; diff --git a/src/plugins/data_source/server/cryptography/cryptography_client.test.ts b/src/plugins/data_source/server/cryptography/cryptography_client.test.ts new file mode 100644 index 000000000000..c1674f1af085 --- /dev/null +++ b/src/plugins/data_source/server/cryptography/cryptography_client.test.ts @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CryptographyClient } from './cryptography_client'; +import { randomBytes } from 'crypto'; + +const dummyWrappingKeyName = 'dummy_wrapping_key_name'; +const dummyWrappingKeyNamespace = 'dummy_wrapping_key_namespace'; + +test('Invalid wrapping key size throws error', () => { + const dummyRandomBytes = [...randomBytes(31)]; + const expectedErrorMsg = `Wrapping key size shoule be 32 bytes, as used in envelope encryption. Current wrapping key size: '${dummyRandomBytes.length}' bytes`; + expect(() => { + new CryptographyClient(dummyWrappingKeyName, dummyWrappingKeyNamespace, dummyRandomBytes); + }).toThrowError(new Error(expectedErrorMsg)); +}); + +// TODO: Add more test cases for encrypt / decrypt https://github.com/opensearch-project/OpenSearch-Dashboards/issues/2068 diff --git a/src/plugins/data_source/server/cryptography/cryptography_client.ts b/src/plugins/data_source/server/cryptography/cryptography_client.ts new file mode 100644 index 000000000000..1d2ce378d771 --- /dev/null +++ b/src/plugins/data_source/server/cryptography/cryptography_client.ts @@ -0,0 +1,70 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + buildClient, + CommitmentPolicy, + RawAesKeyringNode, + RawAesWrappingSuiteIdentifier, +} from '@aws-crypto/client-node'; + +export const ENCODING_STRATEGY: BufferEncoding = 'base64'; +export const WRAPPING_KEY_SIZE: number = 32; + +export class CryptographyClient { + private readonly _commitmentPolicy = CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT; + private readonly _wrappingSuite = RawAesWrappingSuiteIdentifier.AES256_GCM_IV12_TAG16_NO_PADDING; + + private readonly _keyring: RawAesKeyringNode; + + private readonly _encrypt; + private readonly _decrypt; + + /** + * @param {string} wrappingKeyName + * @param {string} wrappingKeyNamespace + * @param {number[]} wrappingKey 32 Bytes raw wrapping key used to perform envelope encryption + */ + constructor(wrappingKeyName: string, wrappingKeyNamespace: string, wrappingKey: number[]) { + if (wrappingKey.length !== WRAPPING_KEY_SIZE) { + const wrappingKeySizeMismatchMsg = `Wrapping key size shoule be 32 bytes, as used in envelope encryption. Current wrapping key size: '${wrappingKey.length}' bytes`; + throw new Error(wrappingKeySizeMismatchMsg); + } + + // Create raw AES keyring + this._keyring = new RawAesKeyringNode({ + keyName: wrappingKeyName, + keyNamespace: wrappingKeyNamespace, + unencryptedMasterKey: new Uint8Array(wrappingKey), + wrappingSuite: this._wrappingSuite, + }); + + // Destructuring encrypt and decrypt functions from client + const { encrypt, decrypt } = buildClient(this._commitmentPolicy); + + this._encrypt = encrypt; + this._decrypt = decrypt; + } + + /** + * Input text content and output encrypted string encoded with ENCODING_STRATEGY + * @param {string} plainText + * @returns {Promise} + */ + public async encrypt(plainText: string | Buffer): Promise { + const result = await this._encrypt(this._keyring, plainText); + return result.result.toString(ENCODING_STRATEGY); + } + + /** + * Input encrypted content and output decrypted string + * @param {string} encrypted + * @returns {Promise} + */ + public async decrypt(encrypted: string): Promise { + const result = await this._decrypt(this._keyring, Buffer.from(encrypted, ENCODING_STRATEGY)); + return result.plaintext.toString(); + } +} diff --git a/src/plugins/data_source/server/cryptography/index.ts b/src/plugins/data_source/server/cryptography/index.ts new file mode 100644 index 000000000000..857fa691bddf --- /dev/null +++ b/src/plugins/data_source/server/cryptography/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { CryptographyClient } from './cryptography_client'; diff --git a/src/plugins/data_source/server/index.ts b/src/plugins/data_source/server/index.ts index 41df94090986..f05b833817d6 100644 --- a/src/plugins/data_source/server/index.ts +++ b/src/plugins/data_source/server/index.ts @@ -3,15 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { schema, TypeOf } from '@osd/config-schema'; import { PluginConfigDescriptor, PluginInitializerContext } from 'src/core/server'; import { DataSourcePlugin } from './plugin'; - -export const configSchema = schema.object({ - enabled: schema.boolean({ defaultValue: false }), -}); - -export type DataSourcePluginConfigType = TypeOf; +import { configSchema, DataSourcePluginConfigType } from '../config'; export const config: PluginConfigDescriptor = { schema: configSchema, diff --git a/src/plugins/data_source/server/plugin.ts b/src/plugins/data_source/server/plugin.ts index b92d6dffd9d7..f1eef0c8506e 100644 --- a/src/plugins/data_source/server/plugin.ts +++ b/src/plugins/data_source/server/plugin.ts @@ -3,20 +3,27 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { first } from 'rxjs/operators'; + import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from 'src/core/server'; -import { dataSource, credential } from './saved_objects'; +import { dataSource, credential, CredentialSavedObjectsClientWrapper } from './saved_objects'; +import { DataSourcePluginConfigType } from '../config'; import { DataSourcePluginSetup, DataSourcePluginStart } from './types'; +import { CryptographyClient } from './cryptography'; + export class DataSourcePlugin implements Plugin { - private readonly logger: Logger; + private readonly _logger: Logger; + private _initializerContext: PluginInitializerContext; constructor(initializerContext: PluginInitializerContext) { - this.logger = initializerContext.logger.get(); + this._initializerContext = initializerContext; + this._logger = initializerContext.logger.get(); } - public setup(core: CoreSetup) { - this.logger.debug('data_source: Setup'); + public async setup(core: CoreSetup) { + this._logger.debug('data_source: Setup'); // Register credential saved object type core.savedObjects.registerType(credential); @@ -24,13 +31,37 @@ export class DataSourcePlugin implements Plugin { + // Create credential saved objects client wrapper + const credentialSavedObjectsClientWrapper = new CredentialSavedObjectsClientWrapper( + cryptographyClient + ); + + // Add credential saved objects client wrapper factory + core.savedObjects.addClientWrapper( + 1, + 'credential', + credentialSavedObjectsClientWrapper.wrapperFactory + ); + }); + return {}; } public start(core: CoreStart) { - this.logger.debug('data_source: Started'); + this._logger.debug('data_source: Started'); return {}; } public stop() {} + + private async createCryptographyClient() { + const { + wrappingKeyName, + wrappingKeyNamespace, + wrappingKey, + } = await this._initializerContext.config.create().pipe(first()).toPromise(); + + return new CryptographyClient(wrappingKeyName, wrappingKeyNamespace, wrappingKey); + } } diff --git a/src/plugins/data_source/server/saved_objects/credential_saved_objects_client_wrapper.ts b/src/plugins/data_source/server/saved_objects/credential_saved_objects_client_wrapper.ts new file mode 100644 index 000000000000..26b2bae84610 --- /dev/null +++ b/src/plugins/data_source/server/saved_objects/credential_saved_objects_client_wrapper.ts @@ -0,0 +1,278 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + HttpServiceStart, + SavedObjectsBulkCreateObject, + SavedObjectsBulkResponse, + SavedObjectsBulkUpdateObject, + SavedObjectsBulkUpdateOptions, + SavedObjectsBulkUpdateResponse, + SavedObjectsClientWrapperFactory, + SavedObjectsCreateOptions, + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, +} from 'opensearch-dashboards/server'; + +import { SavedObjectsErrorHelpers } from '../../../../core/server'; + +import { CryptographyClient } from '../cryptography'; + +import { Credential } from '../../common'; + +/** + * Describes the Credential Saved Objects Client Wrapper class, + * which contains the factory used to create Saved Objects Client Wrapper instances + */ +export class CredentialSavedObjectsClientWrapper { + public httpStart?: HttpServiceStart; + + private _type: string = 'credential'; + private _authType: Credential.SharedAuthType = 'shared'; + private _noAuthType: Credential.NoAuthType = 'no_auth'; + private _usernamePasswordType: Credential.UsernamePasswordType = 'username_password'; + + private _cryptographyClient: CryptographyClient; + + constructor(cryptographyClient: CryptographyClient) { + this._cryptographyClient = cryptographyClient; + } + + /** + * Describes the factory used to create instances of Saved Objects Client Wrappers + * for credential spcific operations such as encryption + * Check {@link Credential.CredentialSavedObjectAttributes} for attributes type details + * Check {@link Credential.CredentialMaterials} for credential materials type and + * credential materials content details + */ + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { + const createWithCredentialMaterialsEncryption = async ( + type: string, + attributes: T, + options?: SavedObjectsCreateOptions + ) => { + this.validateType(type); + this.validateAttributes(attributes); + + const { title, authType, credentialMaterials, description } = attributes; + + if (this._noAuthType === credentialMaterials.credentialMaterialsType) { + return await wrapperOptions.client.create(type, attributes, options); + } + + const encryptCredentialMaterials = await this.validateAndEncryptCredentialMaterials( + credentialMaterials + ); + + const encryptedAttributes = { + title, + authType, + credentialMaterials: encryptCredentialMaterials, + description, + }; + + return await wrapperOptions.client.create(type, encryptedAttributes, options); + }; + + const bulkCreateWithCredentialMaterialsEncryption = async ( + objects: Array>, + options?: SavedObjectsCreateOptions + ): Promise> => { + objects = await Promise.all( + objects.map(async (object) => { + const { type, attributes } = object; + + this.validateType(type); + this.validateAttributes(attributes); + + const { title, authType, credentialMaterials, description } = attributes; + + if (this._noAuthType === credentialMaterials.credentialMaterialsType) { + return object; + } + + const encryptedAttributes = { + title, + authType, + credentialMaterials: await this.validateAndEncryptCredentialMaterials( + credentialMaterials + ), + description, + }; + + return ({ + ...object, + attributes: encryptedAttributes, + // Unfortunately this throws a typescript error without the casting. I think it's due to the + // convoluted way SavedObjects are created. + } as unknown) as SavedObjectsBulkCreateObject; + }) + ); + return await wrapperOptions.client.bulkCreate(objects, options); + }; + + const updateWithCredentialMaterialsEncryption = async ( + type: string, + id: string, + attributes: Partial, + options: SavedObjectsUpdateOptions = {} + ): Promise> => { + this.validateType(type); + this.validateAttributes(attributes); + + const { title, authType, credentialMaterials, description } = attributes; + + if (this._noAuthType === credentialMaterials.credentialMaterialsType) { + return await wrapperOptions.client.create(type, attributes, options); + } + + const encryptedAttributes: Partial = { + title, + authType, + credentialMaterials: await this.validateAndEncryptCredentialMaterials(credentialMaterials), + description, + }; + + return await wrapperOptions.client.update(type, id, encryptedAttributes, options); + }; + + const bulkUpdateWithCredentialMaterialsEncryption = async ( + objects: Array>, + options?: SavedObjectsBulkUpdateOptions + ): Promise> => { + objects = await Promise.all( + objects.map(async (object) => { + const { type, attributes } = object; + + this.validateType(type); + this.validateAttributes(attributes); + + const { title, authType, credentialMaterials, description } = attributes; + + if (this._noAuthType === credentialMaterials.credentialMaterialsType) { + return object; + } + + const encryptedAttributes = { + title, + authType, + credentialMaterials: await this.validateAndEncryptCredentialMaterials( + credentialMaterials + ), + description, + }; + + return ({ + ...object, + attributes: encryptedAttributes, + // Unfortunately this throws a typescript error without the casting. I think it's due to the + // convoluted way SavedObjects are created. + } as unknown) as SavedObjectsBulkUpdateObject; + }) + ); + + return await wrapperOptions.client.bulkUpdate(objects, options); + }; + + return { + ...wrapperOptions.client, + create: createWithCredentialMaterialsEncryption, + bulkCreate: bulkCreateWithCredentialMaterialsEncryption, + checkConflicts: wrapperOptions.client.checkConflicts, + delete: wrapperOptions.client.delete, + find: wrapperOptions.client.find, + bulkGet: wrapperOptions.client.bulkGet, + get: wrapperOptions.client.get, + update: updateWithCredentialMaterialsEncryption, + bulkUpdate: bulkUpdateWithCredentialMaterialsEncryption, + errors: wrapperOptions.client.errors, + addToNamespaces: wrapperOptions.client.addToNamespaces, + deleteFromNamespaces: wrapperOptions.client.deleteFromNamespaces, + }; + }; + + private validateType(type: string) { + if (this._type !== type) { + throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); + } + return; + } + + private validateAttributes(attributes: T) { + const { title, authType, credentialMaterials } = attributes; + + if (title === undefined) { + throw SavedObjectsErrorHelpers.createBadRequestError('attribute "title" required'); + } + + if (authType === undefined) { + throw SavedObjectsErrorHelpers.createBadRequestError('attribute "authType" required'); + } + + if (credentialMaterials === undefined) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'attribute "credentialMaterials" required' + ); + } + + // TODO: Support other auth types https://github.com/opensearch-project/OpenSearch-Dashboards/issues/2110 + if (this._authType !== authType) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `Unsupported authType type: '${attributes.authType}'` + ); + } + return; + } + + private async validateAndEncryptCredentialMaterials(credentialMaterials: T) { + this.validateUsernamePasswordTypedCredentialMaterials(credentialMaterials); + return await this.encryptCredentialMaterials(credentialMaterials); + } + + private validateUsernamePasswordTypedCredentialMaterials(credentialMaterials: T) { + const { credentialMaterialsType, credentialMaterialsContent } = credentialMaterials; + + if (this._usernamePasswordType !== credentialMaterialsType) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `Invalid credential materials type: '${credentialMaterialsType}'` + ); + } + + if (credentialMaterialsContent === undefined) { + throw SavedObjectsErrorHelpers.createBadRequestError( + `attribute "credentialMaterialsContent" required for: '${credentialMaterialsType}'` + ); + } + + this.validateUsernamePasswordTypedContent(credentialMaterialsContent); + + return; + } + + private validateUsernamePasswordTypedContent(credentialMaterialsContent: T) { + const { username, password } = credentialMaterialsContent; + + if (username === undefined) { + throw SavedObjectsErrorHelpers.createBadRequestError('attribute "username" required'); + } + + if (password === undefined) { + throw SavedObjectsErrorHelpers.createBadRequestError('attribute "password" required'); + } + + return; + } + + private async encryptCredentialMaterials(credentialMaterials: T) { + const { credentialMaterialsType, credentialMaterialsContent } = credentialMaterials; + return { + credentialMaterialsType, + credentialMaterialsContent: { + username: credentialMaterialsContent.username, + password: await this._cryptographyClient.encrypt(credentialMaterialsContent.password), + }, + }; + } +} diff --git a/src/plugins/data_source/server/saved_objects/index.ts b/src/plugins/data_source/server/saved_objects/index.ts index aa3411811e88..bd1c8a6613a3 100644 --- a/src/plugins/data_source/server/saved_objects/index.ts +++ b/src/plugins/data_source/server/saved_objects/index.ts @@ -5,3 +5,4 @@ export { credential } from './credential_saved_objects_type'; export { dataSource } from './data_source'; +export { CredentialSavedObjectsClientWrapper } from './credential_saved_objects_client_wrapper';