diff --git a/CHANGELOG.md b/CHANGELOG.md index df35a1f20ba5..7b8f4aac0928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,9 @@ # CHANGELOG + Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## [Unreleased] + ### πŸ’₯ Breaking Changes ### Deprecations @@ -10,18 +12,20 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### πŸ“ˆ Features/Enhancements -* [MD] Support legacy client for data source ([#2204](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2204)) -* [Plugin Helpers] Facilitate version changes ([#2398](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2398)) +- [MD] Support legacy client for data source ([#2204](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2204)) +- [MD] Add data source signing support ([#2510](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2510)) +- [Plugin Helpers] Facilitate version changes ([#2398](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2398)) ### πŸ› Bug Fixes -* [Vis Builder] Fixes auto bounds for timeseries bar chart visualization ([2401](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2401)) -* [Vis Builder] Fixes visualization shift when editing agg ([2401](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2401)) -* [Vis Builder] Renames "Histogram" to "Bar" in vis type picker ([2401](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2401)) -* [MD] Add data source param to low-level search call in Discover ([#2431](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2431)) + +- [Vis Builder] Fixes auto bounds for timeseries bar chart visualization ([2401](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2401)) +- [Vis Builder] Fixes visualization shift when editing agg ([2401](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2401)) +- [Vis Builder] Renames "Histogram" to "Bar" in vis type picker ([2401](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2401)) +- [MD] Add data source param to low-level search call in Discover ([#2431](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2431)) ### 🚞 Infrastructure -* Add CHANGELOG.md and related workflows ([#2414](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2414)) +- Add CHANGELOG.md and related workflows ([#2414](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2414)) ### πŸ“ Documentation @@ -32,56 +36,56 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ### πŸ”© Tests ## [2.x] + ### πŸ’₯ Breaking Changes ### Deprecations ### πŸ›‘ Security -* Use a forced CSP-compliant interpreter with Vega visualizations ([#2352](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2352)) -* Bump moment-timezone from 0.5.34 to 0.5.37 ([#2361](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2361)) -* [CVE-2022-33987] Upgrade geckodriver to 3.0.2 ([#2166](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2166)) -* Bumps percy-agent to use non-beta version ([#2415](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2415)) -* Resolve sub-dependent d3-color version and potential security issue ([#2454](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2454)) +- Use a forced CSP-compliant interpreter with Vega visualizations ([#2352](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2352)) +- Bump moment-timezone from 0.5.34 to 0.5.37 ([#2361](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2361)) +- [CVE-2022-33987] Upgrade geckodriver to 3.0.2 ([#2166](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2166)) +- Bumps percy-agent to use non-beta version ([#2415](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2415)) +- Resolve sub-dependent d3-color version and potential security issue ([#2454](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2454)) ### πŸ“ˆ Features/Enhancements -* Add updated_at column to objects' tables ([#1218](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/1218)) -* [Viz Builder] State validation before dispatching and loading ([#2351](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2351)) -* [Viz Builder] Create a new wizard directly on a dashboard ([#2384](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2384)) +- Add updated_at column to objects' tables ([#1218](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/1218)) +- [Viz Builder] State validation before dispatching and loading ([#2351](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2351)) +- [Viz Builder] Create a new wizard directly on a dashboard ([#2384](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2384)) ### πŸ› Bug Fixes -* [Viz Builder] Fixes time series for new chart types ([#2309](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2309)) -* [Viz Builder] Add index pattern info when loading embeddable ([#2363](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2363)) -* Fixes management app breadcrumb error ([#2344](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2344)) +- [Viz Builder] Fixes time series for new chart types ([#2309](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2309)) +- [Viz Builder] Add index pattern info when loading embeddable ([#2363](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2363)) +- Fixes management app breadcrumb error ([#2344](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2344)) ### 🚞 Infrastructure -* Add path ignore for markdown files for CI ([#2312](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2312)) -* Updating WS scans to ignore BWC artifacts in `cypress` ([#2408](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2408)) - +- Add path ignore for markdown files for CI ([#2312](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2312)) +- Updating WS scans to ignore BWC artifacts in `cypress` ([#2408](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2408)) ### πŸ“ Documentation -* README.md for saving index pattern relationship ([#2276](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2276)) -* Remove extra typo from README. ([#2403](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2403)) -* Add sample config for multi data source feature in yml template. ([#2428](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2428)) -* README.md for dataSource and dataSourceManagement Plugin ([#2448](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2448)) +- README.md for saving index pattern relationship ([#2276](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2276)) +- Remove extra typo from README. ([#2403](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2403)) +- Add sample config for multi data source feature in yml template. ([#2428](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2428)) +- README.md for dataSource and dataSourceManagement Plugin ([#2448](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2448)) ### πŸ›  Maintenance -* Increment from 2.3 to 2.4. ([#2295](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2295)) -* Adding @zengyan-amazon as maintainer ([#2419](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2419)) -* Updating @tmarkley to Emeritus status. ([#2423](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2423)) -* Adding sample config for multi data source in yml config template. ([#2428](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2428)) -* Adding @kristenTian as maintainer. ([#2450](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2450)) +- Increment from 2.3 to 2.4. ([#2295](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2295)) +- Adding @zengyan-amazon as maintainer ([#2419](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2419)) +- Updating @tmarkley to Emeritus status. ([#2423](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2423)) +- Adding sample config for multi data source in yml config template. ([#2428](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2428)) +- Adding @kristenTian as maintainer. ([#2450](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2450)) ### πŸͺ› Refactoring ### πŸ”© Tests -* Update caniuse to fix failed integration tests ([#2322](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2322)) +- Update caniuse to fix failed integration tests ([#2322](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2322)) -[Unreleased]: https://github.com/opensearch-project/OpenSearch-Dashboards/compare/2.3.0...HEAD +[unreleased]: https://github.com/opensearch-project/OpenSearch-Dashboards/compare/2.3.0...HEAD [2.x]: https://github.com/opensearch-project/OpenSearch-Dashboards/compare/2.3.0...2.x diff --git a/src/plugins/data_source/README.md b/src/plugins/data_source/README.md index cfda79a2908f..ccd50a272d16 100755 --- a/src/plugins/data_source/README.md +++ b/src/plugins/data_source/README.md @@ -5,15 +5,17 @@ An OpenSearch Dashboards plugin This plugin introduces support for multiple data sources into OpenSearch Dashboards and provides related functions to connect to OpenSearch data sources. ## Configuration + Update the following configuration in the `opensearch_dashboards.yml` file to apply changes. Refer to the schema [here](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/src/plugins/data_source/config.ts) for supported configurations. 1. The dataSource plugin is disabled by default; to enable it: -`data_source.enabled: true` + `data_source.enabled: true` 2. The audit trail is enabled by default for logging the access to data source; to disable it: -`data_source.audit.enabled: false` + `data_source.audit.enabled: false` + +- Current auditor configuration: - - Current auditor configuration: ``` data_source.audit.appender.kind: 'file' data_source.audit.appender.layout.kind: 'pattern' @@ -21,34 +23,43 @@ data_source.audit.appender.path: '/tmp/opensearch-dashboards-data-source-audit.l ``` 3. The default encryption-related configuration parameters are: + ``` data_source.encryption.wrappingKeyName: 'changeme' data_source.encryption.wrappingKeyNamespace: 'changeme' data_source.encryption.wrappingKey: [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] ``` + Note that if any of the encryption keyring configuration values change (wrappingKeyName/wrappingKeyNamespace/wrappingKey), none of the previously-encrypted credentials can be decrypted; therefore, credentials of previously created data sources must be updated to continue use. **What are the best practices for generating a secure wrapping key?** WrappingKey is an array of 32 random numbers. Read [more](https://en.wikipedia.org/wiki/Cryptographically_secure_pseudorandom_number_generator) about best practices for generating a secure wrapping key. ## Public + The public plugin is used to enable and disable the features related to multi data source available in other plugins. e.g. data_source_management, index_pattern_management - Add as a required dependency for whole plugin on/off switch - Add as opitional dependency for partial flow changes control ## Server + The provided data source client is integrated with default search strategy in data plugin. When data source id presented in IOpenSearchSearchRequest, data source client will be used. ### Data Source Service -The data source service will provide a data source client given a data source id and optional client configurations. + +The data source service will provide a data source client given a data source id and optional client configurations. Currently supported client config is: + - `data_source.clientPool.size` Data source service uses LRU cache to cache the root client to improve client pool usage. + #### Example usage: + In the RequestHandler, get an instance of the client using: + ```ts client: OpenSearchClient = await context.dataSource.opensearch.getClient(dataSourceId); @@ -57,17 +68,19 @@ apiCaller: LegacyAPICaller = context.dataSource.opensearch.legacy.getClient(data ``` ### Data Source Client Wrapper + The data source saved object client wrapper overrides the write related action for data source object in order to perform validation and encryption actions of the authentication information inside data source. -### Cryptography Client -The research for choosing a suitable stack can be found in: [#1756](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/1756) -#### Example usage: -```ts -//Encrypt -const encryptedPassword = await this.cryptographyClient.encryptAndEncode(password); -//Decrypt -const decodedPassword = await this.cryptographyClient.decodeAndDecrypt(password); -``` +### Cryptography service + +The cryptography service performs encryption / decryption on data source credentials (support no_auth and username_password for now). Highlight the following security best practices (see more details on https://github.com/opensearch-project/OpenSearch-Dashboards/issues/1756) + +a. Envelope encryption - has multiple benefits including strong protection on data keys, encryption the same data with multiple wrappign keys, etc + +b. Key derivation algorithm - HKDF with SHA-384, which β€œhelps you avoid accidental reuse of a data encryption key and reduces the risk of overusing a data key.” + +c. Signature algorithm - ECDSA with P-384 and SHA-384. Under multiple data source case, data source indices stored on OpenSearch can be modified / replaced by attacker. With ECDSA signature, ciphertext decryption will fail if it’s getting pullted. No one will be able to create another signature that verifies with the public key because the private key has been dropped. + --- ## Development diff --git a/src/plugins/data_source/server/client/configure_client.test.ts b/src/plugins/data_source/server/client/configure_client.test.ts index 696c7ce2daf6..db833ef8606c 100644 --- a/src/plugins/data_source/server/client/configure_client.test.ts +++ b/src/plugins/data_source/server/client/configure_client.test.ts @@ -14,17 +14,18 @@ import { configureClient } from './configure_client'; import { ClientOptions } from '@opensearch-project/opensearch'; // eslint-disable-next-line @osd/eslint/no-restricted-paths import { opensearchClientMock } from '../../../../core/server/opensearch/client/mocks'; -import { CryptographyClient } from '../cryptography'; +import { cryptographyServiceSetupMock } from '../cryptography_service.mocks'; +import { CryptographyServiceSetup } from '../cryptography_service'; import { DataSourceClientParams } from '../types'; const DATA_SOURCE_ID = 'a54b76ec86771ee865a0f74a305dfff8'; -const cryptoClient = new CryptographyClient('test', 'test', new Array(32).fill(0)); // TODO: improve UT describe('configureClient', () => { let logger: ReturnType; let config: DataSourcePluginConfigType; let savedObjectsMock: jest.Mocked; + let cryptographyMock: jest.Mocked; let clientPoolSetup: OpenSearchClientPoolSetup; let clientOptions: ClientOptions; let dataSourceAttr: DataSourceAttributes; @@ -35,6 +36,8 @@ describe('configureClient', () => { dsClient = opensearchClientMock.createInternalClient(); logger = loggingSystemMock.createLogger(); savedObjectsMock = savedObjectsClientMock.create(); + cryptographyMock = cryptographyServiceSetupMock.create(); + config = { enabled: true, clientPool: { @@ -75,7 +78,7 @@ describe('configureClient', () => { dataSourceClientParams = { dataSourceId: DATA_SOURCE_ID, savedObjects: savedObjectsMock, - cryptographyClient: cryptoClient, + cryptography: cryptographyMock, }; ClientMock.mockImplementation(() => dsClient); @@ -110,7 +113,10 @@ describe('configureClient', () => { }); test('configure client with auth.type == username_password, will first call decrypt()', async () => { - const spy = jest.spyOn(cryptoClient, 'decodeAndDecrypt').mockResolvedValue('password'); + const spy = jest.spyOn(cryptographyMock, 'decodeAndDecrypt').mockResolvedValue({ + decryptedText: 'password', + encryptionContext: { endpoint: 'http://localhost' }, + }); const client = await configureClient(dataSourceClientParams, clientPoolSetup, config, logger); diff --git a/src/plugins/data_source/server/client/configure_client.ts b/src/plugins/data_source/server/client/configure_client.ts index febac85070ea..b84cc53c36cf 100644 --- a/src/plugins/data_source/server/client/configure_client.ts +++ b/src/plugins/data_source/server/client/configure_client.ts @@ -12,14 +12,14 @@ import { UsernamePasswordTypedContent, } from '../../common/data_sources'; import { DataSourcePluginConfigType } from '../../config'; -import { CryptographyClient } from '../cryptography'; +import { CryptographyServiceSetup } from '../cryptography_service'; import { DataSourceConfigError } from '../lib/error'; import { DataSourceClientParams } from '../types'; import { parseClientOptions } from './client_config'; import { OpenSearchClientPoolSetup } from './client_pool'; export const configureClient = async ( - { dataSourceId, savedObjects, cryptographyClient }: DataSourceClientParams, + { dataSourceId, savedObjects, cryptography }: DataSourceClientParams, openSearchClientPoolSetup: OpenSearchClientPoolSetup, config: DataSourcePluginConfigType, logger: Logger @@ -28,7 +28,7 @@ export const configureClient = async ( const dataSource = await getDataSource(dataSourceId, savedObjects); const rootClient = getRootClient(dataSource.attributes, config, openSearchClientPoolSetup); - return await getQueryClient(rootClient, dataSource, cryptographyClient); + return await getQueryClient(rootClient, dataSource, cryptography); } catch (error: any) { logger.error(`Fail to get data source client for dataSourceId: [${dataSourceId}]`); logger.error(error); @@ -50,13 +50,29 @@ export const getDataSource = async ( export const getCredential = async ( dataSource: SavedObject, - cryptographyClient: CryptographyClient + cryptography: CryptographyServiceSetup ): Promise => { + const { endpoint } = dataSource.attributes!; + const { username, password } = dataSource.attributes.auth.credentials!; - const decodedPassword = await cryptographyClient.decodeAndDecrypt(password); + + const { decryptedText, encryptionContext } = await cryptography + .decodeAndDecrypt(password) + .catch(() => { + throw new Error( + 'Encrypted "auth.credentials.password" contaminated. Please delete and create another data source.' + ); + }); + + if (encryptionContext!.endpoint !== endpoint) { + throw new Error( + 'Data source "endpoint" contaminated. Please delete and create another data source.' + ); + } + const credential = { username, - password: decodedPassword, + password: decryptedText, }; return credential; @@ -67,13 +83,13 @@ export const getCredential = async ( * * @param rootClient root client for the connection with given data source endpoint. * @param dataSource data source saved object - * @param cryptographyClient cryptography client for password encryption / decryption + * @param cryptography cryptography service for password encryption / decryption * @returns child client. */ const getQueryClient = async ( rootClient: Client, dataSource: SavedObject, - cryptographyClient: CryptographyClient + cryptography: CryptographyServiceSetup ): Promise => { const authType = dataSource.attributes.auth.type; @@ -82,7 +98,7 @@ const getQueryClient = async ( return rootClient.child(); case AuthType.UsernamePasswordType: - const credential = await getCredential(dataSource, cryptographyClient); + const credential = await getCredential(dataSource, cryptography); return getBasicAuthClient(rootClient, credential); default: diff --git a/src/plugins/data_source/server/cryptography/cryptography_client.test.ts b/src/plugins/data_source/server/cryptography/cryptography_client.test.ts deleted file mode 100644 index 1f8d2596a3c4..000000000000 --- a/src/plugins/data_source/server/cryptography/cryptography_client.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/* - * 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)); -}); - -describe('Test encrpyt and decrypt module', () => { - const dummyPlainText = 'dummy'; - const dummyNumArray1 = [...randomBytes(32)]; - const dummyNumArray2 = [...randomBytes(32)]; - - describe('Positive test cases', () => { - test('Encrypt and Decrypt with same in memory keyring', async () => { - const cryptographyClient = new CryptographyClient( - dummyWrappingKeyName, - dummyWrappingKeyNamespace, - dummyNumArray1 - ); - const encrypted = await cryptographyClient.encryptAndEncode(dummyPlainText); - const outputText = await cryptographyClient.decodeAndDecrypt(encrypted); - expect(outputText).toBe(dummyPlainText); - }); - test('Encrypt and Decrypt with two different keyrings with exact same identifiers', async () => { - const cryptographyClient1 = new CryptographyClient( - dummyWrappingKeyName, - dummyWrappingKeyNamespace, - dummyNumArray1 - ); - const encrypted = await cryptographyClient1.encryptAndEncode(dummyPlainText); - - const cryptographyClient2 = new CryptographyClient( - dummyWrappingKeyName, - dummyWrappingKeyNamespace, - dummyNumArray1 - ); - const outputText = await cryptographyClient2.decodeAndDecrypt(encrypted); - expect(cryptographyClient1 === cryptographyClient2).toBeFalsy(); - expect(outputText).toBe(dummyPlainText); - }); - }); - - describe('Negative test cases', () => { - const defaultWrappingKeyName = 'changeme'; - const defaultWrappingKeyNamespace = 'changeme'; - const expectedErrorMsg = 'unencryptedDataKey has not been set'; - test('Encrypt and Decrypt with different key names', async () => { - const cryptographyClient1 = new CryptographyClient( - dummyWrappingKeyName, - dummyWrappingKeyNamespace, - dummyNumArray1 - ); - const encrypted = await cryptographyClient1.encryptAndEncode(dummyPlainText); - - const cryptographyClient2 = new CryptographyClient( - defaultWrappingKeyName, - dummyWrappingKeyNamespace, - dummyNumArray1 - ); - try { - await cryptographyClient2.decodeAndDecrypt(encrypted); - } catch (error) { - expect(error.message).toMatch(expectedErrorMsg); - } - }); - test('Encrypt and Decrypt with different key namespaces', async () => { - const cryptographyClient1 = new CryptographyClient( - dummyWrappingKeyName, - dummyWrappingKeyNamespace, - dummyNumArray1 - ); - const encrypted = await cryptographyClient1.encryptAndEncode(dummyPlainText); - - const cryptographyClient2 = new CryptographyClient( - dummyWrappingKeyName, - defaultWrappingKeyNamespace, - dummyNumArray1 - ); - try { - await cryptographyClient2.decodeAndDecrypt(encrypted); - } catch (error) { - expect(error.message).toMatch(expectedErrorMsg); - } - }); - test('Encrypt and Decrypt with different wrapping keys', async () => { - const cryptographyClient1 = new CryptographyClient( - dummyWrappingKeyName, - dummyWrappingKeyNamespace, - dummyNumArray1 - ); - const encrypted = await cryptographyClient1.encryptAndEncode(dummyPlainText); - - const cryptographyClient2 = new CryptographyClient( - dummyWrappingKeyName, - dummyWrappingKeyNamespace, - dummyNumArray2 - ); - try { - await cryptographyClient2.decodeAndDecrypt(encrypted); - } catch (error) { - expect(error.message).toMatch(expectedErrorMsg); - } - }); - }); -}); diff --git a/src/plugins/data_source/server/cryptography/cryptography_client.ts b/src/plugins/data_source/server/cryptography/cryptography_client.ts deleted file mode 100644 index f5968ae13adb..000000000000 --- a/src/plugins/data_source/server/cryptography/cryptography_client.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * 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 keyring: RawAesKeyringNode; - - private readonly encrypt: Function; - private readonly decrypt: Function; - - /** - * @param {string} wrappingKeyName name value to identify the AES key in a keyring - * @param {string} wrappingKeyNamespace namespace value to identify the AES key in a keyring, - * @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 encryptAndEncode(plainText: string): 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 decodeAndDecrypt(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 deleted file mode 100644 index 857fa691bddf..000000000000 --- a/src/plugins/data_source/server/cryptography/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export { CryptographyClient } from './cryptography_client'; diff --git a/src/plugins/data_source/server/cryptography_service.mocks.ts b/src/plugins/data_source/server/cryptography_service.mocks.ts new file mode 100644 index 000000000000..d74912dfda9e --- /dev/null +++ b/src/plugins/data_source/server/cryptography_service.mocks.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CryptographyServiceSetup } from './cryptography_service'; + +const create = () => + (({ + encryptAndEncode: jest.fn(), + decodeAndDecrypt: jest.fn(), + } as unknown) as jest.Mocked); + +export const cryptographyServiceSetupMock = { create }; diff --git a/src/plugins/data_source/server/cryptography_service.ts b/src/plugins/data_source/server/cryptography_service.ts new file mode 100644 index 000000000000..410d95164932 --- /dev/null +++ b/src/plugins/data_source/server/cryptography_service.ts @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + buildClient, + CommitmentPolicy, + RawAesKeyringNode, + RawAesWrappingSuiteIdentifier, +} from '@aws-crypto/client-node'; + +import { Logger } from '../../../../src/core/server'; + +import { DataSourcePluginConfigType } from '../config'; + +export const ENCODING_STRATEGY: BufferEncoding = 'base64'; +export const WRAPPING_KEY_SIZE: number = 32; + +export interface EncryptionContext { + endpoint?: string; +} + +export interface RefinedDecryptOutPut { + decryptedText: string; + encryptionContext: EncryptionContext; +} + +export interface CryptographyServiceSetup { + encryptAndEncode: (plainText: string, encryptionContext: EncryptionContext) => Promise; + decodeAndDecrypt: (encrypted: string) => Promise; +} + +export class CryptographyService { + // commitment policy to enable data key derivation and ECDSA signature + private readonly commitmentPolicy = CommitmentPolicy.REQUIRE_ENCRYPT_REQUIRE_DECRYPT; + // algorithm suite identifier to adopt AES-GCM + private readonly wrappingSuite = RawAesWrappingSuiteIdentifier.AES256_GCM_IV12_TAG16_NO_PADDING; + + constructor(private logger: Logger) {} + + setup(config: DataSourcePluginConfigType): CryptographyServiceSetup { + // Fetch configs used to create credential saved objects client wrapper + const { wrappingKeyName, wrappingKeyNamespace, wrappingKey } = config.encryption; + + 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`; + this.logger.error(wrappingKeySizeMismatchMsg); + throw new Error(wrappingKeySizeMismatchMsg); + } + + // Create raw AES keyring + const 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); + + const encryptAndEncode = async (plainText: string, encryptionContext = {}): Promise => { + const result = await encrypt(keyring, plainText, { + encryptionContext, + }); + return result.result.toString(ENCODING_STRATEGY); + }; + + const decodeAndDecrypt = async (encrypted: string): Promise => { + const { plaintext, messageHeader } = await decrypt( + keyring, + Buffer.from(encrypted, ENCODING_STRATEGY) + ); + return { + decryptedText: plaintext.toString(), + encryptionContext: { + endpoint: messageHeader.encryptionContext.endpoint, + }, + }; + }; + + return { encryptAndEncode, decodeAndDecrypt }; + } + + start() {} + + stop() {} +} diff --git a/src/plugins/data_source/server/legacy/configure_legacy_client.test.ts b/src/plugins/data_source/server/legacy/configure_legacy_client.test.ts index 752487741f10..eb0aae719973 100644 --- a/src/plugins/data_source/server/legacy/configure_legacy_client.test.ts +++ b/src/plugins/data_source/server/legacy/configure_legacy_client.test.ts @@ -8,7 +8,8 @@ import { loggingSystemMock, savedObjectsClientMock } from '../../../../core/serv import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../common'; import { AuthType, DataSourceAttributes } from '../../common/data_sources'; import { DataSourcePluginConfigType } from '../../config'; -import { CryptographyClient } from '../cryptography'; +import { cryptographyServiceSetupMock } from '../cryptography_service.mocks'; +import { CryptographyServiceSetup, RefinedDecryptOutPut } from '../cryptography_service'; import { DataSourceClientParams, LegacyClientCallAPIParams } from '../types'; import { OpenSearchClientPoolSetup } from '../client'; import { ConfigOptions } from 'elasticsearch'; @@ -16,13 +17,13 @@ import { ClientMock, parseClientOptionsMock } from './configure_legacy_client.te import { configureLegacyClient } from './configure_legacy_client'; const DATA_SOURCE_ID = 'a54b76ec86771ee865a0f74a305dfff8'; -const cryptographyClient = new CryptographyClient('test', 'test', new Array(32).fill(0)); // TODO: improve UT describe('configureLegacyClient', () => { let logger: ReturnType; let config: DataSourcePluginConfigType; let savedObjectsMock: jest.Mocked; + let cryptographyMock: jest.Mocked; let clientPoolSetup: OpenSearchClientPoolSetup; let configOptions: ConfigOptions; let dataSourceAttr: DataSourceAttributes; @@ -33,7 +34,7 @@ describe('configureLegacyClient', () => { }; let dataSourceClientParams: DataSourceClientParams; let callApiParams: LegacyClientCallAPIParams; - let decodeAndDecryptSpy: jest.SpyInstance, [encrypted: string]>; + let decodeAndDecryptSpy: jest.SpyInstance, [encrypted: string]>; const mockResponse = { data: 'ping' }; @@ -44,6 +45,7 @@ describe('configureLegacyClient', () => { }; logger = loggingSystemMock.createLogger(); savedObjectsMock = savedObjectsClientMock.create(); + cryptographyMock = cryptographyServiceSetupMock.create(); config = { enabled: true, clientPool: { @@ -89,7 +91,7 @@ describe('configureLegacyClient', () => { dataSourceClientParams = { dataSourceId: DATA_SOURCE_ID, savedObjects: savedObjectsMock, - cryptographyClient, + cryptography: cryptographyMock, }; ClientMock.mockImplementation(() => mockOpenSearchClientInstance); @@ -101,9 +103,10 @@ describe('configureLegacyClient', () => { }); }); - decodeAndDecryptSpy = jest - .spyOn(cryptographyClient, 'decodeAndDecrypt') - .mockResolvedValue('password'); + decodeAndDecryptSpy = jest.spyOn(cryptographyMock, 'decodeAndDecrypt').mockResolvedValue({ + decryptedText: 'password', + encryptionContext: { endpoint: 'http://localhost' }, + }); }); afterEach(() => { diff --git a/src/plugins/data_source/server/legacy/configure_legacy_client.ts b/src/plugins/data_source/server/legacy/configure_legacy_client.ts index 0f2a63287e14..861cd38cdefd 100644 --- a/src/plugins/data_source/server/legacy/configure_legacy_client.ts +++ b/src/plugins/data_source/server/legacy/configure_legacy_client.ts @@ -19,14 +19,14 @@ import { UsernamePasswordTypedContent, } from '../../common/data_sources'; import { DataSourcePluginConfigType } from '../../config'; -import { CryptographyClient } from '../cryptography'; +import { CryptographyServiceSetup } from '../cryptography_service'; import { DataSourceClientParams, LegacyClientCallAPIParams } from '../types'; import { OpenSearchClientPoolSetup, getCredential, getDataSource } from '../client'; import { parseClientOptions } from './client_config'; import { DataSourceConfigError } from '../lib/error'; export const configureLegacyClient = async ( - { dataSourceId, savedObjects, cryptographyClient }: DataSourceClientParams, + { dataSourceId, savedObjects, cryptography }: DataSourceClientParams, callApiParams: LegacyClientCallAPIParams, openSearchClientPoolSetup: OpenSearchClientPoolSetup, config: DataSourcePluginConfigType, @@ -36,7 +36,7 @@ export const configureLegacyClient = async ( const dataSource = await getDataSource(dataSourceId, savedObjects); const rootClient = getRootClient(dataSource.attributes, config, openSearchClientPoolSetup); - return await getQueryClient(rootClient, dataSource, cryptographyClient, callApiParams); + return await getQueryClient(rootClient, dataSource, cryptography, callApiParams); } catch (error: any) { logger.error(`Fail to get data source client for dataSourceId: [${dataSourceId}]`); logger.error(error); @@ -50,13 +50,13 @@ export const configureLegacyClient = async ( * * @param rootClient root client for the connection with given data source endpoint. * @param dataSource data source saved object - * @param cryptographyClient cryptography client for password encryption / decryption + * @param cryptography cryptography service for password encryption / decryption * @returns child client. */ const getQueryClient = async ( rootClient: Client, dataSource: SavedObject, - cryptographyClient: CryptographyClient, + cryptography: CryptographyServiceSetup, { endpoint, clientParams, options }: LegacyClientCallAPIParams ) => { const authType = dataSource.attributes.auth.type; @@ -69,7 +69,7 @@ const getQueryClient = async ( options ); case AuthType.UsernamePasswordType: - const credential = await getCredential(dataSource, cryptographyClient); + const credential = await getCredential(dataSource, cryptography); return getBasicAuthClient(rootClient, { endpoint, clientParams, options }, credential); default: diff --git a/src/plugins/data_source/server/plugin.ts b/src/plugins/data_source/server/plugin.ts index c166fb736768..9b2095a535a9 100644 --- a/src/plugins/data_source/server/plugin.ts +++ b/src/plugins/data_source/server/plugin.ts @@ -20,7 +20,8 @@ import { } from '../../../../src/core/server'; import { DataSourcePluginConfigType } from '../config'; import { LoggingAuditor } from './audit/logging_auditor'; -import { CryptographyClient } from './cryptography'; + +import { CryptographyService, CryptographyServiceSetup } from './cryptography_service'; import { DataSourceService, DataSourceServiceSetup } from './data_source_service'; import { DataSourceSavedObjectsClientWrapper, dataSource } from './saved_objects'; import { DataSourcePluginSetup, DataSourcePluginStart } from './types'; @@ -30,11 +31,13 @@ import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../common'; import { ensureRawRequest } from '../../../../src/core/server/http/router'; export class DataSourcePlugin implements Plugin { private readonly logger: Logger; + private readonly cryptographyService: CryptographyService; private readonly dataSourceService: DataSourceService; private readonly config$: Observable; constructor(private initializerContext: PluginInitializerContext) { this.logger = this.initializerContext.logger.get(); + this.cryptographyService = new CryptographyService(this.logger.get('cryptography-service')); this.dataSourceService = new DataSourceService(this.logger.get('data-source-service')); this.config$ = this.initializerContext.config.create(); } @@ -47,17 +50,13 @@ export class DataSourcePlugin implements Plugin( map((dataSourceConfig) => ({ @@ -94,12 +91,13 @@ export class DataSourcePlugin implements Plugin coreStart.auditTrail); + const dataSourceService: DataSourceServiceSetup = await this.dataSourceService.setup(config); // Register data source plugin context to route handler context core.http.registerRouteHandlerContext( 'dataSource', this.createDataSourceRouteHandlerContext( dataSourceService, - cryptographyClient, + cryptographyServiceSetup, this.logger, auditTrailPromise ) @@ -120,7 +118,7 @@ export class DataSourcePlugin implements Plugin ): IContextProvider, 'dataSource'> => { @@ -129,12 +127,13 @@ export class DataSourcePlugin implements Plugin { const auditor = auditTrailPromise.then((auditTrail) => auditTrail.asScoped(req)); + this.logAuditMessage(auditor, dataSourceId, req); return dataSourceService.getDataSourceClient({ dataSourceId, savedObjects: context.core.savedObjects.client, - cryptographyClient, + cryptography, }); }, legacy: { @@ -142,7 +141,7 @@ export class DataSourcePlugin implements Plugin = await this.validateAndUpdatePartialAttributes( - attributes + wrapperOptions, + id, + attributes, + options ); return await wrapperOptions.client.update(type, id, encryptedAttributes, options); @@ -92,14 +96,17 @@ export class DataSourceSavedObjectsClientWrapper { ): Promise> => { objects = await Promise.all( objects.map(async (object) => { - const { type, attributes } = object; + const { id, type, attributes } = object; if (DATA_SOURCE_SAVED_OBJECT_TYPE !== type) { return object; } const encryptedAttributes: Partial = await this.validateAndUpdatePartialAttributes( - attributes + wrapperOptions, + id, + attributes, + options ); return { @@ -141,26 +148,39 @@ export class DataSourceSavedObjectsClientWrapper { private async validateAndEncryptAttributes(attributes: T) { this.validateAttributes(attributes); - const { auth } = attributes; + const { endpoint, auth } = attributes; switch (auth.type) { case AuthType.NoAuth: return { ...attributes, // Drop the credentials attribute for no_auth - credentials: undefined, + auth: { + type: auth.type, + credentials: undefined, + }, }; case AuthType.UsernamePasswordType: + // Signing the data source with endpoint + const encryptionContext = { + endpoint, + }; + return { ...attributes, - auth: await this.encryptCredentials(auth), + auth: await this.encryptCredentials(auth, encryptionContext), }; default: - throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid auth type: '${type}'`); + throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid auth type: '${auth.type}'`); } } - private async validateAndUpdatePartialAttributes(attributes: T) { + private async validateAndUpdatePartialAttributes( + wrapperOptions: SavedObjectsClientWrapperOptions, + id: string, + attributes: Partial, + options: SavedObjectsUpdateOptions = {} + ) { const { auth, endpoint } = attributes; if (endpoint) { @@ -169,7 +189,7 @@ export class DataSourceSavedObjectsClientWrapper { ); } - if (auth === undefined) { + if (!auth) { return attributes; } @@ -180,13 +200,23 @@ export class DataSourceSavedObjectsClientWrapper { return { ...attributes, // Drop the credentials attribute for no_auth - credentials: undefined, + auth: { + type: auth.type, + credentials: null, + }, }; case AuthType.UsernamePasswordType: if (credentials?.password) { + // Fetch and validate existing signature + const encryptionContext = await this.validateEncryptionContext( + wrapperOptions, + id, + options + ); + return { ...attributes, - auth: await this.encryptCredentials(auth), + auth: await this.encryptCredentials(auth, encryptionContext), }; } else { return attributes; @@ -208,7 +238,7 @@ export class DataSourceSavedObjectsClientWrapper { throw SavedObjectsErrorHelpers.createBadRequestError('"endpoint" attribute is not valid'); } - if (auth === undefined) { + if (!auth) { throw SavedObjectsErrorHelpers.createBadRequestError('"auth" attribute is required'); } @@ -226,7 +256,7 @@ export class DataSourceSavedObjectsClientWrapper { case AuthType.NoAuth: break; case AuthType.UsernamePasswordType: - if (credentials === undefined) { + if (!credentials) { throw SavedObjectsErrorHelpers.createBadRequestError( '"auth.credentials" attribute is required' ); @@ -252,7 +282,92 @@ export class DataSourceSavedObjectsClientWrapper { } } - private async encryptCredentials(auth: T) { + private async validateEncryptionContext( + wrapperOptions: SavedObjectsClientWrapperOptions, + id: string, + options: SavedObjectsUpdateOptions = {} + ) { + let attributes; + + try { + // Fetch existing data source by id + const savedObject = await wrapperOptions.client.get(DATA_SOURCE_SAVED_OBJECT_TYPE, id, { + namespace: options.namespace, + }); + attributes = savedObject.attributes; + } catch (err: any) { + // this.logger.error(err); + throw err; + } + + if (!attributes) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Update failed due to deprecated data source: "attributes" missing. Please delete and create another data source.' + ); + } + + const { endpoint, auth } = attributes; + + if (!endpoint) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Update failed due to deprecated data source: "endpoint" missing. Please delete and create another data source.' + ); + } + + if (!auth) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Update failed due to deprecated data source: "auth" missing. Please delete and create another data source.' + ); + } + + switch (auth.type) { + case AuthType.NoAuth: + // Signing the data source with exsiting endpoint + return { + endpoint, + }; + case AuthType.UsernamePasswordType: + const { credentials } = auth; + if (!credentials) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Update failed due to deprecated data source: "credentials" missing. Please delete and create another data source.' + ); + } + + const { username, password } = credentials; + + if (!username) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Update failed due to deprecated data source: "auth.credentials.username" missing. Please delete and create another data source.' + ); + } + + if (!password) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Update failed due to deprecated data source: "auth.credentials.username" missing. Please delete and create another data source.' + ); + } + + const { encryptionContext } = await this.cryptography + .decodeAndDecrypt(password) + .catch(() => { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Update failed due to deprecated data source: encrypted "auth.credentials.password" contaminated. Please delete and create another data source.' + ); + }); + + if (encryptionContext.endpoint !== endpoint) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Update failed due to deprecated data source: "endpoint" contaminated. Please delete and create another data source.' + ); + } + return encryptionContext; + default: + throw SavedObjectsErrorHelpers.createBadRequestError(`Invalid auth type: '${type}'`); + } + } + + private async encryptCredentials(auth: T, encryptionContext: EncryptionContext) { const { credentials: { username, password }, } = auth; @@ -261,7 +376,7 @@ export class DataSourceSavedObjectsClientWrapper { ...auth, credentials: { username, - password: await this.cryptographyClient.encryptAndEncode(password), + password: await this.cryptography.encryptAndEncode(password, encryptionContext), }, }; } diff --git a/src/plugins/data_source/server/types.ts b/src/plugins/data_source/server/types.ts index 2f20363b4b2d..b5cc61a772bd 100644 --- a/src/plugins/data_source/server/types.ts +++ b/src/plugins/data_source/server/types.ts @@ -8,7 +8,8 @@ import { OpenSearchClient, SavedObjectsClientContract, } from 'src/core/server'; -import { CryptographyClient } from './cryptography'; + +import { CryptographyServiceSetup } from './cryptography_service'; export interface LegacyClientCallAPIParams { endpoint: string; @@ -20,7 +21,7 @@ export interface DataSourceClientParams { dataSourceId: string; // this saved objects client is used to fetch data source on behalf of users, caller should pass scoped saved objects client savedObjects: SavedObjectsClientContract; - cryptographyClient: CryptographyClient; + cryptography: CryptographyServiceSetup; } export interface DataSourcePluginRequestContext {