diff --git a/packages/base/src/locale/en-US/dmsDataSource.ts b/packages/base/src/locale/en-US/dmsDataSource.ts index bbbc113c6c..9f47c79f86 100644 --- a/packages/base/src/locale/en-US/dmsDataSource.ts +++ b/packages/base/src/locale/en-US/dmsDataSource.ts @@ -119,6 +119,11 @@ export default { returnModify: 'Back to Edit', continueSubmit: 'Continue Submit' }, + + passwordTransport: { + encryptFailed: 'Failed to encrypt password. Please try again later.' + }, + testConnectModal: { errorTitle: 'DB instance {{instanceName}} connectivity test failed' }, diff --git a/packages/base/src/locale/zh-CN/dmsDataSource.ts b/packages/base/src/locale/zh-CN/dmsDataSource.ts index 8f6560581d..33ce932dc8 100644 --- a/packages/base/src/locale/zh-CN/dmsDataSource.ts +++ b/packages/base/src/locale/zh-CN/dmsDataSource.ts @@ -111,6 +111,10 @@ export default { continueSubmit: '继续提交' }, + passwordTransport: { + encryptFailed: '口令加密失败,请稍后重试' + }, + testConnectModal: { errorTitle: '数据源{{instanceName}}连通性测试失败' }, diff --git a/packages/base/src/page/DataSource/components/AddDataSource/index.test.tsx b/packages/base/src/page/DataSource/components/AddDataSource/index.test.tsx index 8b717a3ec3..a2ecc85e1d 100644 --- a/packages/base/src/page/DataSource/components/AddDataSource/index.test.tsx +++ b/packages/base/src/page/DataSource/components/AddDataSource/index.test.tsx @@ -15,6 +15,20 @@ import EmitterKey from '../../../../data/EmitterKey'; import EventEmitter from '../../../../utils/EventEmitter'; import { mockProjectList } from '@actiontech/shared/lib/testUtil/mockApi/base/project/data'; import AddDataSource from '.'; +import { encryptPasswordForTransport } from '../../../../utils/passwordTransportEncryption'; + +jest.mock('../../../../utils/passwordTransportEncryption', () => ({ + encryptPasswordForTransport: jest.fn(async () => ({ + secret_password: 'mocked-secret-password-b64' + })), + PasswordTransportError: class PasswordTransportError extends Error { + reason: string; + constructor(reason: string) { + super(reason); + this.reason = reason; + } + } +})); jest.mock('react-router-dom', () => { return { @@ -37,6 +51,9 @@ describe('page/DataSource/AddDataSource', () => { beforeEach(() => { jest.useFakeTimers(); + (encryptPasswordForTransport as jest.Mock).mockResolvedValue({ + secret_password: 'mocked-secret-password-b64' + }); (useNavigate as jest.Mock).mockImplementation(() => navigateSpy); baseMockApi.global.mockAllApi(); getProjectListSpy = baseMockApi.project.getProjectList(); @@ -178,7 +195,7 @@ describe('page/DataSource/AddDataSource', () => { db_type: 'mysql', host: '1.1.1.1', user: 'root', - password: 'root', + secret_password: 'mocked-secret-password-b64', port: '3306', additional_params: [ { @@ -205,7 +222,7 @@ describe('page/DataSource/AddDataSource', () => { host: '1.1.1.1', maintenance_times: [], name: 'name-database', - password: 'root', + secret_password: 'mocked-secret-password-b64', port: '3306', sqle_config: { audit_enabled: true, @@ -431,7 +448,7 @@ describe('page/DataSource/AddDataSource', () => { host: '1.1.1.1', maintenance_times: [], name: 'name-database', - password: 'root', + secret_password: 'mocked-secret-password-b64', port: '3306', sqle_config: { audit_enabled: true, diff --git a/packages/base/src/page/DataSource/components/AddDataSource/index.tsx b/packages/base/src/page/DataSource/components/AddDataSource/index.tsx index c435a06f6f..8aafa3b5d6 100644 --- a/packages/base/src/page/DataSource/components/AddDataSource/index.tsx +++ b/packages/base/src/page/DataSource/components/AddDataSource/index.tsx @@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useBoolean } from 'ahooks'; import { useCallback } from 'react'; import { useForm } from 'antd/es/form/Form'; -import { Space, Typography } from 'antd'; +import { Space, Typography, message } from 'antd'; import { PageLayoutHasFixedHeaderStyleWrapper } from '@actiontech/dms-kit'; import { BasicButton, @@ -20,9 +20,14 @@ import { DataSourceFormField } from '../Form/index.type'; import { DmsApi } from '@actiontech/shared/lib/api'; import { DataSourceFormContextProvide } from '../../context'; import useCheckConnectable from '../../hooks/useCheckConnectable'; +import { + encryptPasswordForTransport, + PasswordTransportError +} from '../../../../utils/passwordTransportEncryption'; const AddDataSource = () => { const { t } = useTranslation(); const navigate = useTypedNavigate(); + const [messageApi, messageContextHolder] = message.useMessage(); const [form] = useForm(); const { onCheckConnectable, loading, connectAble, connectErrorMessage } = useCheckConnectable(form); @@ -30,8 +35,30 @@ const AddDataSource = () => { useBoolean(); const [submitLoading, { setTrue: startSubmit, setFalse: submitFinish }] = useBoolean(); + + const resolveTransportErrorMessage = useCallback( + (error: unknown) => { + if ( + error instanceof PasswordTransportError && + error.reason === 'encrypt_failed' + ) { + return t('dmsDataSource.passwordTransport.encryptFailed'); + } + return t('dmsDataSource.passwordTransport.encryptFailed'); + }, + [t] + ); + const addDatabase = async (values: DataSourceFormField) => { startSubmit(); + let cipher; + try { + cipher = await encryptPasswordForTransport(values.password ?? ''); + } catch (error) { + messageApi.error(resolveTransportErrorMessage(error)); + submitFinish(); + return; + } const dbService: IDBServiceV2 = { name: values.name, desc: values.describe, @@ -39,7 +66,7 @@ const AddDataSource = () => { host: values.ip, port: values.port.toString(), user: values.user, - password: values.password, + secret_password: cipher.secret_password, environment_tag_uid: values.environmentTagId, maintenance_times: values.maintenanceTime?.map((time) => ({ @@ -104,6 +131,7 @@ const AddDataSource = () => { }; return ( + {messageContextHolder} ({ + encryptPasswordForTransport: jest.fn(async () => ({ + secret_password: 'mocked-secret-password-b64' + })), + PasswordTransportError: class PasswordTransportError extends Error { + reason: string; + constructor(reason: string) { + super(reason); + this.reason = reason; + } + } +})); jest.mock('react-router-dom', () => { return { @@ -42,6 +56,9 @@ describe('page/DataSource/UpdateDataSource', () => { beforeEach(() => { jest.useFakeTimers(); + (encryptPasswordForTransport as jest.Mock).mockResolvedValue({ + secret_password: 'mocked-secret-password-b64' + }); (useNavigate as jest.Mock).mockImplementation(() => navigateSpy); jest.spyOn(Router, 'useParams').mockReturnValue({ dbServiceUid: uId @@ -199,6 +216,10 @@ describe('page/DataSource/UpdateDataSource', () => { await act(async () => jest.advanceTimersByTime(3000)); expect(updateDBServiceSpy).toHaveBeenCalledTimes(1); const firstCallParams = updateDBServiceSpy.mock.calls[0][0]; + expect(firstCallParams.db_service.password).toBeUndefined(); + expect(firstCallParams.db_service.secret_password).toBe( + 'mocked-secret-password-b64' + ); expect( firstCallParams.db_service.sqle_config.sql_query_config .workflow_exec_enabled @@ -229,6 +250,8 @@ describe('page/DataSource/UpdateDataSource', () => { await act(async () => jest.advanceTimersByTime(0)); expect(updateDBServiceSpy).toHaveBeenCalledTimes(1); const params = updateDBServiceSpy.mock.calls[0][0]; + expect(params.db_service.password).toBeUndefined(); + expect(params.db_service.secret_password).toBeUndefined(); expect( params.db_service.sqle_config.sql_query_config.workflow_exec_enabled ).toBe(true); diff --git a/packages/base/src/page/DataSource/components/UpdateDataSource/index.tsx b/packages/base/src/page/DataSource/components/UpdateDataSource/index.tsx index eb9d65694b..6c7e0f4aa8 100644 --- a/packages/base/src/page/DataSource/components/UpdateDataSource/index.tsx +++ b/packages/base/src/page/DataSource/components/UpdateDataSource/index.tsx @@ -19,6 +19,10 @@ import { ROUTE_PATHS } from '@actiontech/dms-kit'; import { DmsApi } from '@actiontech/shared/lib/api'; import { DataSourceFormContextProvide } from '../../context'; import useCheckConnectable from '../../hooks/useCheckConnectable'; +import { + encryptPasswordForTransport, + PasswordTransportError +} from '../../../../utils/passwordTransportEncryption'; const UpdateDataSource = () => { const { t } = useTranslation(); const navigate = useTypedNavigate(); @@ -36,6 +40,20 @@ const UpdateDataSource = () => { const [instanceInfo, setInstanceInfo] = useState< IListDBServiceV2 | undefined >(); + + const resolveTransportErrorMessage = useCallback( + (error: unknown) => { + if ( + error instanceof PasswordTransportError && + error.reason === 'encrypt_failed' + ) { + return t('dmsDataSource.passwordTransport.encryptFailed'); + } + return t('dmsDataSource.passwordTransport.encryptFailed'); + }, + [t] + ); + const updateDatabase = async (values: DataSourceFormField) => { startSubmit(); const params: IUpdateDBServiceV2Params = { @@ -91,7 +109,15 @@ const UpdateDataSource = () => { // #endif if (!!values.needUpdatePassword && !!values.password && params.db_service) { - params.db_service.password = values.password; + let cipher; + try { + cipher = await encryptPasswordForTransport(values.password); + } catch (error) { + messageApi.error(resolveTransportErrorMessage(error)); + submitFinish(); + return; + } + params.db_service.secret_password = cipher.secret_password; } return DmsApi.DBServiceService.UpdateDBServiceV2(params) .then((res) => { diff --git a/packages/base/src/page/DataSource/hooks/useCheckConnectable.ts b/packages/base/src/page/DataSource/hooks/useCheckConnectable.ts index 4e2199f0e8..04d29b6c30 100644 --- a/packages/base/src/page/DataSource/hooks/useCheckConnectable.ts +++ b/packages/base/src/page/DataSource/hooks/useCheckConnectable.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import { Form, FormInstance } from 'antd'; +import { useTranslation } from 'react-i18next'; import { DataSourceFormField } from '../components/Form/index.type'; import { useBoolean } from 'ahooks'; import { DmsApi } from '@actiontech/shared/lib/api/'; @@ -10,8 +11,13 @@ import { getDbServiceIsConnectbale } from '../../../utils/common'; import { mergeRedisConnectionModeIntoParams } from '../tool'; +import { + encryptPasswordForTransport, + PasswordTransportError +} from '../../../utils/passwordTransportEncryption'; const useCheckConnectable = (form: FormInstance) => { + const { t } = useTranslation(); const projectID = Form.useWatch('project', form); const [loading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = @@ -21,6 +27,19 @@ const useCheckConnectable = (form: FormInstance) => { const { mergeFromValueIntoParams } = useAsyncParams(); + const resolveTransportErrorMessage = useCallback( + (error: unknown) => { + if ( + error instanceof PasswordTransportError && + error.reason === 'encrypt_failed' + ) { + return t('dmsDataSource.passwordTransport.encryptFailed'); + } + return t('dmsDataSource.passwordTransport.encryptFailed'); + }, + [t] + ); + const onCheckConnectable = useCallback( async (currentAsyncParams?: BackendFormItemParams[]) => { const values = await form.validateFields([ @@ -48,13 +67,23 @@ const useCheckConnectable = (form: FormInstance) => { ); setLoadingTrue(); + let cipher; + try { + cipher = await encryptPasswordForTransport(values.password ?? ''); + } catch (error) { + setConnectAble(false); + setConnectErrorMessage(resolveTransportErrorMessage(error)); + setLoadingFalse(); + return false; + } + return DmsApi.DBServiceService.CheckDBServiceIsConnectable({ db_service: { host: values.ip, port: `${values.port}`, user: values.user, db_type: values.type, - password: values.password, + secret_password: cipher.secret_password, additional_params: values.asyncParams ?? [] }, project_uid: projectID @@ -75,7 +104,14 @@ const useCheckConnectable = (form: FormInstance) => { setLoadingFalse(); }); }, - [form, projectID, mergeFromValueIntoParams, setLoadingTrue, setLoadingFalse] + [ + form, + projectID, + mergeFromValueIntoParams, + setLoadingTrue, + setLoadingFalse, + resolveTransportErrorMessage + ] ); return { diff --git a/packages/base/src/utils/__tests__/passwordTransportEncryption.test.ts b/packages/base/src/utils/__tests__/passwordTransportEncryption.test.ts new file mode 100644 index 0000000000..1731f4b675 --- /dev/null +++ b/packages/base/src/utils/__tests__/passwordTransportEncryption.test.ts @@ -0,0 +1,33 @@ +/** + * @jest-environment node + */ +import { + encryptPasswordForTransport, + PASSWORD_TRANSPORT_SECRET_KEY +} from '../passwordTransportEncryption'; + +describe('passwordTransportEncryption (fixed AES)', () => { + it('uses the backend compile-time SecretKey constant', () => { + expect(PASSWORD_TRANSPORT_SECRET_KEY).toBe( + '471F77D078C5994BD06B65B8B5B1935B' + ); + }); + + it('encrypts to Base64 AES-256-CBC matching Go pkg/aes', async () => { + // Reference ciphertexts from dms-ee pkg/aes NewEncryptor(SecretKey).AesEncrypt + await expect(encryptPasswordForTransport('test')).resolves.toEqual({ + secret_password: 'MbFLdPIZEb/G2QAHPMxSuQ==' + }); + await expect(encryptPasswordForTransport('admin')).resolves.toEqual({ + secret_password: 'lvwmEx6EhCnyMw9cEOKIXQ==' + }); + }); + + it('returns only secret_password (no encryption_key_id)', async () => { + const cipher = await encryptPasswordForTransport('hello'); + expect(cipher).toEqual({ + secret_password: 'aFPI8tYp2iiuN/6Kk30TZw==' + }); + expect(Object.keys(cipher)).toEqual(['secret_password']); + }); +}); diff --git a/packages/base/src/utils/passwordTransportEncryption.ts b/packages/base/src/utils/passwordTransportEncryption.ts new file mode 100644 index 0000000000..66336c0389 --- /dev/null +++ b/packages/base/src/utils/passwordTransportEncryption.ts @@ -0,0 +1,160 @@ +/** + * Shared outbound password transport helper (connect / create / update). + * Fixed AES-256-CBC, same SecretKey and wire format as backend pkg/aes. + * Implemented in-page so HTTP (non-secure context) does not need crypto.subtle. + */ + +/** Must match backend `pkg/dms-common/pkg/aes.SecretKey`. */ +export const PASSWORD_TRANSPORT_SECRET_KEY = '471F77D078C5994BD06B65B8B5B1935B'; + +export type PasswordTransportCipher = { + secret_password: string; +}; + +export type PasswordTransportErrorReason = 'encrypt_failed'; + +export class PasswordTransportError extends Error { + reason: PasswordTransportErrorReason; + + constructor(reason: PasswordTransportErrorReason, message?: string) { + super(message ?? reason); + this.name = 'PasswordTransportError'; + this.reason = reason; + } +} + +const SBOX = [ + 99, 124, 119, 123, 242, 107, 111, 197, 48, 1, 103, 43, 254, 215, 171, 118, + 202, 130, 201, 125, 250, 89, 71, 240, 173, 212, 162, 175, 156, 164, 114, 192, + 183, 253, 147, 38, 54, 63, 247, 204, 52, 165, 229, 241, 113, 216, 49, 21, 4, + 199, 35, 195, 24, 150, 5, 154, 7, 18, 128, 226, 235, 39, 178, 117, 9, 131, 44, + 26, 27, 110, 90, 160, 82, 59, 214, 179, 41, 227, 47, 132, 83, 209, 0, 237, 32, + 252, 177, 91, 106, 203, 190, 57, 74, 76, 88, 207, 208, 239, 170, 251, 67, 77, + 51, 133, 69, 249, 2, 127, 80, 60, 159, 168, 81, 163, 64, 143, 146, 157, 56, + 245, 188, 182, 218, 33, 16, 255, 243, 210, 205, 12, 19, 236, 95, 151, 68, 23, + 196, 167, 126, 61, 100, 93, 25, 115, 96, 129, 79, 220, 34, 42, 144, 136, 70, + 238, 184, 20, 222, 94, 11, 219, 224, 50, 58, 10, 73, 6, 36, 92, 194, 211, 172, + 98, 145, 149, 228, 121, 231, 200, 55, 109, 141, 213, 78, 169, 108, 86, 244, + 234, 101, 122, 174, 8, 186, 120, 37, 46, 28, 166, 180, 198, 232, 221, 116, 31, + 75, 189, 139, 138, 112, 62, 181, 102, 72, 3, 246, 14, 97, 53, 87, 185, 134, + 193, 29, 158, 225, 248, 152, 17, 105, 217, 142, 148, 155, 30, 135, 233, 206, + 85, 40, 223, 140, 161, 137, 13, 191, 230, 66, 104, 65, 153, 45, 15, 176, 84, + 187, 22 +]; +const RCON = [0, 1, 2, 4, 8, 16, 32, 64, 128, 27, 54]; +const xt = (a: number): number => ((a << 1) ^ ((a >>> 7) * 0x11b)) & 255; + +function expandKey(key: Uint8Array): Uint8Array { + const nr = 14; + const w = new Uint8Array(16 * (nr + 1)); + w.set(key); + let bytes = 32; + let rcon = 1; + const t = new Uint8Array(4); + while (bytes < w.length) { + t.set(w.subarray(bytes - 4, bytes)); + if (bytes % 32 === 0) { + const a = t[0]; + t[0] = SBOX[t[1]] ^ RCON[rcon++]; + t[1] = SBOX[t[2]]; + t[2] = SBOX[t[3]]; + t[3] = SBOX[a]; + } else if (bytes % 32 === 16) { + t[0] = SBOX[t[0]]; + t[1] = SBOX[t[1]]; + t[2] = SBOX[t[2]]; + t[3] = SBOX[t[3]]; + } + for (let i = 0; i < 4; i++) { + w[bytes] = w[bytes - 32] ^ t[i]; + bytes++; + } + } + return w; +} + +function encryptBlock(block: Uint8Array, roundKeys: Uint8Array): Uint8Array { + const s = new Uint8Array(block); + const nr = 14; + const add = (round: number): void => { + const off = round * 16; + for (let i = 0; i < 16; i++) s[i] ^= roundKeys[off + i]; + }; + const subShift = () => { + for (let i = 0; i < 16; i++) s[i] = SBOX[s[i]]; + const t = new Uint8Array(s); + for (let r = 1; r < 4; r++) { + for (let c = 0; c < 4; c++) s[r + 4 * c] = t[r + 4 * ((c + r) & 3)]; + } + }; + const mix = () => { + for (let c = 0; c < 4; c++) { + const i = 4 * c; + const a0 = s[i], + a1 = s[i + 1], + a2 = s[i + 2], + a3 = s[i + 3]; + s[i] = xt(a0) ^ (xt(a1) ^ a1) ^ a2 ^ a3; + s[i + 1] = a0 ^ xt(a1) ^ (xt(a2) ^ a2) ^ a3; + s[i + 2] = a0 ^ a1 ^ xt(a2) ^ (xt(a3) ^ a3); + s[i + 3] = xt(a0) ^ a0 ^ a1 ^ a2 ^ xt(a3); + } + }; + add(0); + for (let round = 1; round < nr; round++) { + subShift(); + mix(); + add(round); + } + subShift(); + add(nr); + return s; +} + +const bytesToBase64 = (bytes: Uint8Array): string => { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +}; + +/** + * Encrypts plaintext with AES-256-CBC (IV = key[:16], PKCS7, Base64). + * Never falls back to plaintext. + */ +export const encryptPasswordForTransport = async ( + plainPassword: string +): Promise => { + try { + const key = new TextEncoder().encode(PASSWORD_TRANSPORT_SECRET_KEY); + const roundKeys = expandKey(key); + const iv = key.slice(0, 16); + const data = new TextEncoder().encode(plainPassword); + const pad = 16 - (data.length % 16); + const buf = new Uint8Array(data.length + pad); + buf.set(data); + buf.fill(pad, data.length); + const out = new Uint8Array(buf.length); + let prev: Uint8Array = iv; + for (let offset = 0; offset < buf.length; offset += 16) { + const block = buf.slice(offset, offset + 16); + for (let i = 0; i < 16; i++) { + block[i] ^= prev[i]; + } + const encrypted = encryptBlock(block, roundKeys); + out.set(encrypted, offset); + prev = encrypted; + } + const secret_password = bytesToBase64(out); + if (!secret_password) { + throw new PasswordTransportError('encrypt_failed'); + } + return { secret_password }; + } catch (error) { + if (error instanceof PasswordTransportError) { + throw error; + } + throw new PasswordTransportError('encrypt_failed'); + } +}; diff --git a/packages/shared/lib/api/base/service/common.d.ts b/packages/shared/lib/api/base/service/common.d.ts index d8e289e8f5..774c70b9f5 100644 --- a/packages/shared/lib/api/base/service/common.d.ts +++ b/packages/shared/lib/api/base/service/common.d.ts @@ -666,10 +666,18 @@ export interface ICheckDbConnectable { host: string; - password: string; + /** + * Plaintext password — forbidden on form connect after S1; kept optional for legacy callers. + */ + password?: string; port: string; + /** + * Transport ciphertext (fixed AES-256-CBC, Base64). Preferred for form connect after S1. + */ + secret_password?: string; + user: string; } @@ -936,10 +944,18 @@ export interface IDBServiceV2 { name: string; - password: string; + /** + * Plaintext password — forbidden on create after S2; kept optional for legacy callers. + */ + password?: string; port: string; + /** + * Transport ciphertext (fixed AES-256-CBC, Base64). Preferred for create after S2. + */ + secret_password?: string; + sqle_config?: ISQLEConfig; user: string; @@ -3988,10 +4004,18 @@ export interface IUpdateDBServiceV2 { maintenance_times: IMaintenanceTime[]; + /** + * Plaintext password — forbidden on update after S3; kept optional for legacy callers. + */ password?: string; port: string; + /** + * Transport ciphertext (fixed AES-256-CBC, Base64). Preferred when updating password after S3. + */ + secret_password?: string; + sqle_config?: ISQLEConfig; user: string;