diff --git a/.env.e2e.example b/.env.e2e.example index d2626dcc22..22b5d138f8 100644 --- a/.env.e2e.example +++ b/.env.e2e.example @@ -98,3 +98,7 @@ DEFAULT_SMS_VERIFY_CODE_PROVIDER= # in nodejs 17+ that SSL v3 causes some ecosystem libraries to become incompatible. Configuring this option can prevent upgrading SSL V3 # NODE_OPTIONS=--openssl-legacy-provider + +################# ENCRYPTION FIELD ################# + +ENCRYPTION_FIELD_KEY= diff --git a/.env.example b/.env.example index 2a199a7926..7b9637efd4 100644 --- a/.env.example +++ b/.env.example @@ -70,3 +70,7 @@ INIT_ROOT_EMAIL=admin@nocobase.com INIT_ROOT_PASSWORD=admin123 INIT_ROOT_NICKNAME=Super Admin INIT_ROOT_USERNAME=nocobase + +################# ENCRYPTION FIELD ################# + +ENCRYPTION_FIELD_KEY= diff --git a/.env.test.example b/.env.test.example index f34afedcd1..1b79c4c9eb 100644 --- a/.env.test.example +++ b/.env.test.example @@ -68,3 +68,7 @@ INIT_ALI_SMS_VERIFY_CODE_SIGN= # use any string name (no space) DEFAULT_SMS_VERIFY_CODE_PROVIDER= + +################# ENCRYPTION FIELD ################# + +ENCRYPTION_FIELD_KEY= diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 00cbe3e091..fa396d60fd 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -160,6 +160,7 @@ jobs: DB_PASSWORD: password DB_DATABASE: nocobase APPEND_PRESET_LOCAL_PLUGINS: ${{ steps.vars.outputs.var2 }} + ENCRYPTION_FIELD_KEY: 1%&glK; { + let db: MockDatabase; + + beforeEach(async () => { + db = mockDatabase(); + await db.clean({ drop: true }); + db.registerFieldTypes({ + encryption: EncryptionField, + }); + }); + + afterEach(async () => { + await db.close(); + }); + + it('basic', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'encryption', + name: 'name1', + iv: '1234567890123456', + }, + ], + }); + await db.sync(); + const r = db.getRepository('tests'); + const model = await r.create({ + values: { + name1: 'aaa', + }, + }); + expect(model.get('name1')).not.toBe('aaa'); + const model2 = await r.findOne(); + expect(model2.get('name1')).toBe('aaa'); + }); + + it('should throw error when value is object', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'encryption', + name: 'name1', + iv: '1234567890123456', + }, + ], + }); + await db.sync(); + const r = db.getRepository('tests'); + let err: Error; + try { + await r.create({ + values: { + name1: { obj: 'aaa' }, + }, + }); + } catch (error) { + err = error; + } + expect(err?.message).toBe('string violation: name1 cannot be an array or an object'); + }); + + it('should throw error when value is number', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'encryption', + name: 'name1', + iv: '1234567890123456', + }, + ], + }); + await db.sync(); + const r = db.getRepository('tests'); + let err: Error; + try { + await r.create({ + values: { + name1: 123, + }, + }); + } catch (error) { + err = error; + } + expect(err?.message).toBe('Encrypt Failed: The value must be a string, but got number'); + }); + + it('should throw error when `iv` incorrect', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'encryption', + name: 'name1', + iv: '1', + }, + ], + }); + await db.sync(); + const r = db.getRepository('tests'); + let err: Error; + try { + await r.create({ + values: { + name1: 'aaa', + }, + }); + } catch (error) { + err = error; + } + expect(err.message).toBe('Encrypt Failed: The `iv` must be a 16-character string'); + }); + + it('should not throw error when value is `null` or `undefined` or empty string', async () => { + db.collection({ + name: 'tests', + fields: [ + { + type: 'encryption', + name: 'name1', + iv: '1234567890123456', + }, + ], + }); + await db.sync(); + const r = db.getRepository('tests'); + const fn = vitest.fn(); + try { + await r.create({ + values: [ + { + name1: null, + }, + { + name1: undefined, + }, + { + name1: '', + }, + ], + }); + } catch { + fn(); + } + expect(fn).toBeCalledTimes(0); + }); + + // 无法测,因为 keyStr 项目启动时读取的环境变量,所以测试用例中修改没用 + // it('should throw error when `ENCRYPTION_FIELD_KEY` not exists', async () => { + // const key = process.env.ENCRYPTION_FIELD_KEY; + // process.env.ENCRYPTION_FIELD_KEY = ''; + + // db.collection({ + // name: 'tests', + // fields: [ + // { + // type: 'encryption', + // name: 'name1', + // iv: '1234567890123456', + // }, + // ], + // }); + // await db.sync(); + // const r = db.getRepository('tests'); + // let err: Error; + // try { + // await r.create({ + // values: { + // name1: { obj: 'aaa' }, + // }, + // }); + // } catch (error) { + // err = error; + // } + // expect(err).toBeTruthy(); + + // process.env.ENCRYPTION_FIELD_KEY = key; + // }); +}); diff --git a/packages/core/database/src/fields/encryption-field/encryption-field.ts b/packages/core/database/src/fields/encryption-field/encryption-field.ts new file mode 100644 index 0000000000..72b1dbb300 --- /dev/null +++ b/packages/core/database/src/fields/encryption-field/encryption-field.ts @@ -0,0 +1,89 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { DataTypes, Model } from 'sequelize'; +import { EncryptionError } from './errors/EncryptionError'; +import { aesCheckKey, aesDecrypt, aesEncrypt } from './utils'; +import { BaseColumnFieldOptions, Field } from '../field'; + +export interface EncryptionFieldOptions extends BaseColumnFieldOptions { + type: 'encryption'; + hidden?: boolean; +} + +export class EncryptionField extends Field { + get dataType() { + return DataTypes.STRING; + } + + init() { + aesCheckKey(); + const { name, iv } = this.options; + this.writeListener = async (model: Model) => { + aesCheckKey(); + if (!model.changed(name as any)) { + return; + } + const value = model.get(name) as string; + if (value !== undefined && value !== null) { + try { + const encrypted = await aesEncrypt(value, iv); + model.set(name, encrypted); + } catch (error) { + console.error(error); + if (error instanceof EncryptionError) { + throw error; + } else { + throw new EncryptionError('Encryption failed'); + } + } + } else { + model.set(name, null); + } + }; + + this.findListener = async (instances, options) => { + aesCheckKey(); + instances = Array.isArray(instances) ? instances : [instances]; + await Promise.all( + instances.map(async (instance) => { + const value = instance.get?.(name); + if (value !== undefined && value !== null) { + try { + instance.set(name, await aesDecrypt(value, iv)); + } catch (error) { + console.error(error); + if (error instanceof EncryptionError) { + throw error; + } else { + throw new EncryptionError( + 'Decryption failed, the environment variable `ENCRYPTION_FIELD_KEY` may be incorrect', + ); + } + } + } + return instance; + }), + ); + }; + } + + bind() { + super.bind(); + // @ts-ignore + this.on('afterFind', this.findListener); + this.on('beforeSave', this.writeListener); + } + + unbind() { + super.unbind(); + this.off('afterFind', this.findListener); + this.off('beforeSave', this.writeListener); + } +} diff --git a/packages/core/database/src/fields/encryption-field/errors/EncryptionError.ts b/packages/core/database/src/fields/encryption-field/errors/EncryptionError.ts new file mode 100644 index 0000000000..4b12e9e535 --- /dev/null +++ b/packages/core/database/src/fields/encryption-field/errors/EncryptionError.ts @@ -0,0 +1,15 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export class EncryptionError extends Error { + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + this.name = 'EncryptionError'; + } +} diff --git a/packages/core/database/src/fields/encryption-field/index.ts b/packages/core/database/src/fields/encryption-field/index.ts new file mode 100644 index 0000000000..d808bbeb69 --- /dev/null +++ b/packages/core/database/src/fields/encryption-field/index.ts @@ -0,0 +1,12 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +export * from './encryption-field'; +export * from './utils'; +export * from './errors/EncryptionError'; diff --git a/packages/core/database/src/fields/encryption-field/utils.ts b/packages/core/database/src/fields/encryption-field/utils.ts new file mode 100644 index 0000000000..9ccbabf4ff --- /dev/null +++ b/packages/core/database/src/fields/encryption-field/utils.ts @@ -0,0 +1,126 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ +import crypto from 'crypto'; +import { EncryptionError } from './errors/EncryptionError'; +const algorithm = 'aes-256-cbc'; + +const keyString = process.env.ENCRYPTION_FIELD_KEY || ''; +const defaultIvString = process.env.ENCRYPTION_FIELD_IV || 'Vc53-4G(rTi0vg@a'; // 如果没有设置 IV,使用默认值 + +// 将字符串转换为 Buffer 对象 +const key = Buffer.from(keyString, 'utf8'); + +export function aesEncrypt(text: string, ivString: string = defaultIvString) { + checkValueAndIv('Encrypt', text, ivString); + + return new Promise((resolve, reject) => { + const iv = Buffer.from(ivString, 'utf8'); + const cipher = crypto.createCipheriv(algorithm, key, iv); + + let encrypted = ''; + + cipher.setEncoding('hex'); + + cipher.on('data', (chunk) => { + encrypted += chunk; + }); + + cipher.on('end', () => { + resolve(encrypted); + }); + + cipher.on('error', (err) => { + reject(err); + }); + + cipher.write(text); + cipher.end(); + }); +} + +export function aesDecrypt(encrypted: string, ivString: string = defaultIvString) { + checkValueAndIv('Decrypt', encrypted, ivString); + + return new Promise((resolve, reject) => { + const iv = Buffer.from(ivString, 'utf8'); + const decipher = crypto.createDecipheriv(algorithm, key, iv); + + let decrypted = ''; + + decipher.setEncoding('utf8'); + + decipher.on('data', (chunk) => { + decrypted += chunk; + }); + + decipher.on('end', () => { + resolve(decrypted); + }); + + decipher.on('error', (err) => { + reject(err); + }); + + decipher.write(encrypted, 'hex'); + decipher.end(); + }); +} + +export function aesEncryptSync(text: string, ivString: string = defaultIvString) { + checkValueAndIv('Encrypt', text, ivString); + + const iv = Buffer.from(ivString, 'utf8'); + const cipher = crypto.createCipheriv(algorithm, key, iv); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + return encrypted; +} + +export function aseDecryptSync(encrypted: string, ivString: string = defaultIvString) { + checkValueAndIv('Decrypt', encrypted, ivString); + + const iv = Buffer.from(ivString, 'utf8'); + const decipher = crypto.createDecipheriv(algorithm, key, iv); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; +} + +export function aesCheckKey() { + if (!keyString) { + throw new EncryptionError('The environment variable `ENCRYPTION_FIELD_KEY` is required, please set it'); + } + if (typeof keyString !== 'string') { + throw new EncryptionError('The environment variable `ENCRYPTION_FIELD_KEY` must be a string'); + } + if (keyString.length !== 32) { + throw new EncryptionError('The environment variable `ENCRYPTION_FIELD_KEY` must be a 32-character string'); + } +} + +export function checkValueAndIv(type: 'Decrypt' | 'Encrypt', value: string, iv: string) { + const msg = `${type} Failed: `; + if (typeof value !== 'string') { + throw new EncryptionError(msg + 'The value must be a string, but got ' + typeof value); + } + + if (type === 'Decrypt') { + if (value.length % 2 !== 0) { + throw new EncryptionError(msg + `The encrypted value is invalid, not a hex string. The value is "${value}"`); + } + } + + if (typeof iv !== 'string') { + throw new EncryptionError(msg + 'The `iv` must be a string, but got ' + typeof iv); + } + + if (iv.length !== 16) { + throw new EncryptionError(msg + 'The `iv` must be a 16-character string'); + } +} diff --git a/packages/core/database/src/fields/index.ts b/packages/core/database/src/fields/index.ts index 2bb02a1fd0..610b6f1ad1 100644 --- a/packages/core/database/src/fields/index.ts +++ b/packages/core/database/src/fields/index.ts @@ -35,6 +35,7 @@ import { UidFieldOptions } from './uid-field'; import { UUIDFieldOptions } from './uuid-field'; import { VirtualFieldOptions } from './virtual-field'; import { NanoidFieldOptions } from './nanoid-field'; +import { EncryptionField } from './encryption-field'; export * from './array-field'; export * from './belongs-to-field'; @@ -59,6 +60,7 @@ export * from './uid-field'; export * from './uuid-field'; export * from './virtual-field'; export * from './nanoid-field'; +export * from './encryption-field'; export type FieldOptions = | BaseFieldOptions @@ -87,4 +89,5 @@ export type FieldOptions = | BelongsToFieldOptions | HasOneFieldOptions | HasManyFieldOptions - | BelongsToManyFieldOptions; + | BelongsToManyFieldOptions + | EncryptionField; diff --git a/packages/core/database/src/model-hook.ts b/packages/core/database/src/model-hook.ts index 751e8deb24..ef025221ec 100644 --- a/packages/core/database/src/model-hook.ts +++ b/packages/core/database/src/model-hook.ts @@ -36,7 +36,10 @@ export class ModelHook { } findModelName(hookArgs) { - for (const arg of hookArgs) { + for (let arg of hookArgs) { + if (Array.isArray(arg)) { + arg = arg[0]; + } if (arg?._previousDataValues) { return (arg).constructor.name; } diff --git a/packages/core/database/src/view/field-type-map.ts b/packages/core/database/src/view/field-type-map.ts index c43d41739d..8a31465a65 100644 --- a/packages/core/database/src/view/field-type-map.ts +++ b/packages/core/database/src/view/field-type-map.ts @@ -8,9 +8,9 @@ */ const postgres = { - 'character varying': ['string', 'uuid', 'nanoid'], - varchar: ['string', 'uuid', 'nanoid'], - char: ['string', 'uuid', 'nanoid'], + 'character varying': ['string', 'uuid', 'nanoid', 'encryption'], + varchar: ['string', 'uuid', 'nanoid', 'encryption'], + char: ['string', 'uuid', 'nanoid', 'encryption'], character: 'string', text: 'text', @@ -53,8 +53,8 @@ const mysql = { 'tinyint unsigned': ['integer', 'boolean', 'sort'], 'mediumint unsigned': ['integer', 'boolean', 'sort'], - char: ['string', 'uuid', 'nanoid'], - varchar: ['string', 'uuid', 'nanoid'], + char: ['string', 'uuid', 'nanoid', 'encryption'], + varchar: ['string', 'uuid', 'nanoid', 'encryption'], date: 'date', time: 'time', tinytext: 'text', @@ -79,7 +79,7 @@ const mysql = { const sqlite = { text: 'text', - varchar: ['string', 'uuid', 'nanoid'], + varchar: ['string', 'uuid', 'nanoid', 'encryption'], integer: 'integer', real: 'real',