From e8dd1eaa7d6466781c75b598a87d5bcb2a21f24b Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Sun, 20 Sep 2026 23:43:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=95=86=E6=88=B7?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=8A=A0=E5=AF=86=E5=AF=86=E9=92=A5=E7=AE=A1?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sql/消息加密密钥管理.sql | 137 ++++++++ .../api/admin/imconfiginternal/im_config.go | 33 +- .../im_message_encryption.go | 36 +++ .../internal/consts/im_message_encryption.go | 12 + .../admin/sys/im_message_encryption.go | 41 +++ .../internalapi/imconfig/im_config.go | 22 ++ .../internal/library/immessagecrypto/key.go | 76 +++++ server/internal/logic/sys/dict_data.go | 18 +- server/internal/logic/sys/dict_type.go | 19 +- .../logic/sys/im_message_encryption.go | 298 ++++++++++++++++++ server/internal/logic/sys/im_tenant.go | 41 ++- .../input/sysin/im_message_encryption.go | 78 +++++ .../internal/model/input/sysin/im_tenant.go | 112 +++---- .../router/genrouter/im_message_encryption.go | 7 + server/internal/service/sys.go | 128 ++++---- .../data/generate/im_message_encryption.sql | 137 ++++++++ server/utility/encrypt/aes.go | 46 +++ server/utility/encrypt/aes_gcm_test.go | 34 ++ web/src/api/imMessageEncryption/index.ts | 17 + web/src/views/imMessageEncryption/index.vue | 137 ++++++++ web/src/views/imMessageEncryption/model.ts | 87 +++++ web/src/views/imTenant/edit.vue | 9 + web/src/views/imTenant/model.ts | 14 + 23 files changed, 1417 insertions(+), 122 deletions(-) create mode 100644 docs/admin-adfahi-research/sql/消息加密密钥管理.sql create mode 100644 server/api/admin/immessageencryption/im_message_encryption.go create mode 100644 server/internal/consts/im_message_encryption.go create mode 100644 server/internal/controller/admin/sys/im_message_encryption.go create mode 100644 server/internal/library/immessagecrypto/key.go create mode 100644 server/internal/logic/sys/im_message_encryption.go create mode 100644 server/internal/model/input/sysin/im_message_encryption.go create mode 100644 server/internal/router/genrouter/im_message_encryption.go create mode 100644 server/storage/data/generate/im_message_encryption.sql create mode 100644 server/utility/encrypt/aes_gcm_test.go create mode 100644 web/src/api/imMessageEncryption/index.ts create mode 100644 web/src/views/imMessageEncryption/index.vue create mode 100644 web/src/views/imMessageEncryption/model.ts diff --git a/docs/admin-adfahi-research/sql/消息加密密钥管理.sql b/docs/admin-adfahi-research/sql/消息加密密钥管理.sql new file mode 100644 index 0000000..219690d --- /dev/null +++ b/docs/admin-adfahi-research/sql/消息加密密钥管理.sql @@ -0,0 +1,137 @@ +-- 消息加密密钥管理。 +-- 可重复执行。根 KEK 只在首次执行时生成,重复执行不会覆盖已有值。 + +SET NAMES utf8mb4; +SET @now := NOW(); + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config' + AND COLUMN_NAME = 'message_encryption_enabled') = 0, + 'ALTER TABLE `im_tenant_config` ADD COLUMN `message_encryption_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否启用消息加密'' AFTER `local_moderation_enabled`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +CREATE TABLE IF NOT EXISTS `im_message_encryption_keys` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL COMMENT '商户ID', + `key_version` int unsigned NOT NULL COMMENT '密钥版本', + `algorithm` varchar(32) NOT NULL DEFAULT 'AES-256-GCM' COMMENT '数据加密算法', + `encrypted_dek` blob NOT NULL COMMENT '由根KEK包裹后的商户DEK', + `dek_nonce` varbinary(12) NOT NULL COMMENT '包裹DEK使用的随机nonce', + `status` varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/active/retired/disabled', + `activated_at` datetime DEFAULT NULL, + `retired_at` datetime DEFAULT NULL, + `remark` varchar(255) NOT NULL DEFAULT '', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `updated_by` bigint NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_message_key_tenant_version` (`tenant_id`, `key_version`), + KEY `idx_im_message_key_tenant_status` (`tenant_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商户消息加密密钥版本'; + +INSERT INTO `hg_sys_dict_type` + (`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 0, 'IM内部安全材料', 'im_internal_secret', 9999, + '仅供服务端读取,通用字典接口禁止查询和维护', 1, @now, @now +WHERE NOT EXISTS ( + SELECT 1 FROM `hg_sys_dict_type` WHERE `type` = 'im_internal_secret' +); + +INSERT INTO `hg_sys_dict_data` + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 'core_material_01', TO_BASE64(RANDOM_BYTES(32)), 'string', 'im_internal_secret', + '', 2, 1, '消息DEK包裹根密钥KEK,请勿通过后台展示或修改', 1, @now, @now +WHERE NOT EXISTS ( + SELECT 1 FROM `hg_sys_dict_data` + WHERE `type` = 'im_internal_secret' AND `label` = 'core_material_01' +); + +SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1); +SET @settingsId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imSystemSettings' LIMIT 1); + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @settingsId, '消息密钥管理', 'imMessageEncryptionIndex', 'message-encryption/index', + 'KeyOutlined', 2, '', '/imMessageEncryption/list', '', '/imMessageEncryption/index', + 1, 'imManager', 0, 0, '', 0, 0, 0, 3, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), 80, + '商户消息加密开关及密钥版本管理', 1, @now, @now +WHERE @settingsId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionIndex'); + +SET @keyMenuId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionIndex' LIMIT 1); + +UPDATE `hg_admin_menu` +SET `pid` = @settingsId, + `title` = '消息密钥管理', + `path` = 'message-encryption/index', + `icon` = 'KeyOutlined', + `permissions` = '/imMessageEncryption/list', + `component` = '/imMessageEncryption/index', + `active_menu` = 'imManager', + `level` = 3, + `tree` = CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), + `sort` = 80, + `hidden` = 0, + `status` = 1, + `updated_at` = @now +WHERE `id` = @keyMenuId; + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @keyMenuId, '初始化密钥', 'imMessageEncryptionInitialize', '', '', 3, '', + '/imMessageEncryption/initialize', '', '', 1, '', 0, 0, '', 0, 1, 0, 4, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 10, '', 1, @now, @now +WHERE @keyMenuId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionInitialize'); + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @keyMenuId, '轮换密钥', 'imMessageEncryptionRotate', '', '', 3, '', + '/imMessageEncryption/rotate', '', '', 1, '', 0, 0, '', 0, 1, 0, 4, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 20, '', 1, @now, @now +WHERE @keyMenuId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionRotate'); + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @keyMenuId, '切换消息加密', 'imMessageEncryptionSwitch', '', '', 3, '', + '/imMessageEncryption/switch', '', '', 1, '', 0, 0, '', 0, 1, 0, 4, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 30, '', 1, @now, @now +WHERE @keyMenuId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionSwitch'); + +UPDATE `hg_admin_menu` +SET `level` = 4, + `tree` = CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), + `updated_at` = @now +WHERE `pid` = @keyMenuId AND `type` = 3; + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT 1, `id` FROM `hg_admin_menu` +WHERE `name` IN ( + 'imManager', + 'imSystemSettings', + 'imMessageEncryptionIndex', + 'imMessageEncryptionInitialize', + 'imMessageEncryptionRotate', + 'imMessageEncryptionSwitch' +); diff --git a/server/api/admin/imconfiginternal/im_config.go b/server/api/admin/imconfiginternal/im_config.go index 2c495a3..7692a16 100644 --- a/server/api/admin/imconfiginternal/im_config.go +++ b/server/api/admin/imconfiginternal/im_config.go @@ -1,6 +1,10 @@ package imconfiginternal -import "github.com/gogf/gf/v2/frame/g" +import ( + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) type TenantReq struct { g.Meta `path:"/internal/im-config/tenants/by-code/{code}" method:"get" tags:"IM内部配置" summary:"按编码获取IM租户"` @@ -155,6 +159,33 @@ type SensitiveWordsRes struct { Items []*SensitiveWordModel `json:"items"` } +type MessageKeyActiveReq struct { + g.Meta `path:"/internal/im-config/tenants/{tenantCode}/message-keys/active" method:"get" tags:"IM内部配置" summary:"获取商户当前消息密钥"` + TenantCode string `json:"tenantCode" in:"path"` +} + +type MessageKeyActiveRes struct { + *sysin.ImMessageKeyModel +} + +type MessageKeyVersionReq struct { + g.Meta `path:"/internal/im-config/tenants/{tenantCode}/message-keys/{version}" method:"get" tags:"IM内部配置" summary:"获取商户指定版本消息密钥"` + TenantCode string `json:"tenantCode" in:"path"` + Version uint `json:"version" in:"path"` +} + +type MessageKeyVersionRes struct { + *sysin.ImMessageKeyModel +} + +type MessageKeyAckReq struct { + g.Meta `path:"/internal/im-config/tenants/{tenantCode}/message-keys/ack" method:"post" tags:"IM内部配置" summary:"确认商户消息密钥可用"` + TenantCode string `json:"tenantCode" in:"path"` + Version uint `json:"version" v:"required|min:1#密钥版本不能为空|密钥版本不正确"` +} + +type MessageKeyAckRes struct{} + type SensitiveWordModel struct { ID int64 `json:"id"` Word string `json:"word"` diff --git a/server/api/admin/immessageencryption/im_message_encryption.go b/server/api/admin/immessageencryption/im_message_encryption.go new file mode 100644 index 0000000..064b8d7 --- /dev/null +++ b/server/api/admin/immessageencryption/im_message_encryption.go @@ -0,0 +1,36 @@ +package immessageencryption + +import ( + "hotgo/internal/model/input/form" + "hotgo/internal/model/input/sysin" + + "github.com/gogf/gf/v2/frame/g" +) + +type ListReq struct { + g.Meta `path:"/imMessageEncryption/list" method:"get" tags:"消息加密密钥" summary:"获取商户消息加密状态"` + sysin.ImMessageEncryptionListInp +} + +type ListRes struct { + form.PageRes + List []*sysin.ImMessageEncryptionListModel `json:"list" dc:"数据列表"` +} + +type InitializeReq struct { + g.Meta `path:"/imMessageEncryption/initialize" method:"post" tags:"消息加密密钥" summary:"初始化商户消息密钥"` + sysin.ImMessageEncryptionActionInp +} +type InitializeRes struct{} + +type RotateReq struct { + g.Meta `path:"/imMessageEncryption/rotate" method:"post" tags:"消息加密密钥" summary:"轮换商户消息密钥"` + sysin.ImMessageEncryptionActionInp +} +type RotateRes struct{} + +type SwitchReq struct { + g.Meta `path:"/imMessageEncryption/switch" method:"post" tags:"消息加密密钥" summary:"切换商户消息加密"` + sysin.ImMessageEncryptionSwitchInp +} +type SwitchRes struct{} diff --git a/server/internal/consts/im_message_encryption.go b/server/internal/consts/im_message_encryption.go new file mode 100644 index 0000000..a3e85bf --- /dev/null +++ b/server/internal/consts/im_message_encryption.go @@ -0,0 +1,12 @@ +package consts + +const ( + ImInternalSecretDictType = "im_internal_secret" + ImMessageRootKEKLabel = "core_material_01" + + ImMessageKeyAlgorithm = "AES-256-GCM" + ImMessageKeyStatusPending = "pending" + ImMessageKeyStatusActive = "active" + ImMessageKeyStatusRetired = "retired" + ImMessageKeyStatusDisabled = "disabled" +) diff --git a/server/internal/controller/admin/sys/im_message_encryption.go b/server/internal/controller/admin/sys/im_message_encryption.go new file mode 100644 index 0000000..00e560e --- /dev/null +++ b/server/internal/controller/admin/sys/im_message_encryption.go @@ -0,0 +1,41 @@ +package sys + +import ( + "context" + "hotgo/api/admin/immessageencryption" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" +) + +var ImMessageEncryption = cImMessageEncryption{} + +type cImMessageEncryption struct{} + +func (c *cImMessageEncryption) List(ctx context.Context, req *immessageencryption.ListReq) (res *immessageencryption.ListRes, err error) { + list, total, err := service.SysImMessageEncryption().List(ctx, &req.ImMessageEncryptionListInp) + if err != nil { + return nil, err + } + if list == nil { + list = []*sysin.ImMessageEncryptionListModel{} + } + res = new(immessageencryption.ListRes) + res.List = list + res.PageRes.Pack(req, total) + return res, nil +} + +func (c *cImMessageEncryption) Initialize(ctx context.Context, req *immessageencryption.InitializeReq) (res *immessageencryption.InitializeRes, err error) { + err = service.SysImMessageEncryption().Initialize(ctx, &req.ImMessageEncryptionActionInp) + return +} + +func (c *cImMessageEncryption) Rotate(ctx context.Context, req *immessageencryption.RotateReq) (res *immessageencryption.RotateRes, err error) { + err = service.SysImMessageEncryption().Rotate(ctx, &req.ImMessageEncryptionActionInp) + return +} + +func (c *cImMessageEncryption) Switch(ctx context.Context, req *immessageencryption.SwitchReq) (res *immessageencryption.SwitchRes, err error) { + err = service.SysImMessageEncryption().Switch(ctx, &req.ImMessageEncryptionSwitchInp) + return +} diff --git a/server/internal/controller/internalapi/imconfig/im_config.go b/server/internal/controller/internalapi/imconfig/im_config.go index d218fa9..2532d9d 100644 --- a/server/internal/controller/internalapi/imconfig/im_config.go +++ b/server/internal/controller/internalapi/imconfig/im_config.go @@ -9,6 +9,7 @@ import ( "hotgo/internal/library/immoderation" "hotgo/internal/library/imtenant" "hotgo/internal/model/entity" + "hotgo/internal/model/input/sysin" "hotgo/internal/service" "strings" @@ -382,6 +383,27 @@ func (c *cConfig) SensitiveWords(ctx context.Context, req *api.SensitiveWordsReq return res, nil } +func (c *cConfig) MessageKeyActive(ctx context.Context, req *api.MessageKeyActiveReq) (res *api.MessageKeyActiveRes, err error) { + key, err := service.SysImMessageEncryption().GetKey(ctx, &sysin.ImMessageKeyGetInp{TenantCode: req.TenantCode}) + if err != nil { + return nil, err + } + return &api.MessageKeyActiveRes{ImMessageKeyModel: key}, nil +} + +func (c *cConfig) MessageKeyVersion(ctx context.Context, req *api.MessageKeyVersionReq) (res *api.MessageKeyVersionRes, err error) { + key, err := service.SysImMessageEncryption().GetKey(ctx, &sysin.ImMessageKeyGetInp{TenantCode: req.TenantCode, Version: req.Version}) + if err != nil { + return nil, err + } + return &api.MessageKeyVersionRes{ImMessageKeyModel: key}, nil +} + +func (c *cConfig) MessageKeyAck(ctx context.Context, req *api.MessageKeyAckReq) (res *api.MessageKeyAckRes, err error) { + err = service.SysImMessageEncryption().Ack(ctx, &sysin.ImMessageKeyAckInp{TenantCode: req.TenantCode, Version: req.Version}) + return +} + func defaultModerationPolicy(tenant *entity.ImTenant) *api.ModerationPolicyRes { return &api.ModerationPolicyRes{TenantID: tenant.Id, TenantCode: tenant.Code, Enabled: false, AliyunEnabled: false, LocalWordsEnabled: false, Scenes: cloneBoolMap(defaultModerationScenes), diff --git a/server/internal/library/immessagecrypto/key.go b/server/internal/library/immessagecrypto/key.go new file mode 100644 index 0000000..9d1b16f --- /dev/null +++ b/server/internal/library/immessagecrypto/key.go @@ -0,0 +1,76 @@ +package immessagecrypto + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/binary" + "hotgo/internal/consts" + "hotgo/internal/dao" + "hotgo/utility/encrypt" + + "github.com/gogf/gf/v2/errors/gerror" +) + +func GenerateDEK() ([]byte, error) { + key := make([]byte, encrypt.AESGCMKeySize) + if _, err := rand.Read(key); err != nil { + return nil, gerror.Wrap(err, "生成商户消息密钥失败") + } + return key, nil +} + +func WrapDEK(ctx context.Context, tenantID int64, version uint, dek []byte) (ciphertext, nonce []byte, err error) { + kek, err := loadRootKEK(ctx) + if err != nil { + return nil, nil, err + } + return encrypt.AESGCMEncrypt(dek, kek, keyAAD(tenantID, version)) +} + +func UnwrapDEK(ctx context.Context, tenantID int64, version uint, ciphertext, nonce []byte) ([]byte, error) { + kek, err := loadRootKEK(ctx) + if err != nil { + return nil, err + } + dek, err := encrypt.AESGCMDecrypt(ciphertext, nonce, kek, keyAAD(tenantID, version)) + if err != nil { + return nil, gerror.Wrap(err, "解包商户消息密钥失败") + } + if len(dek) != encrypt.AESGCMKeySize { + return nil, gerror.New("商户消息密钥长度不正确") + } + return dek, nil +} + +func EncodeDEK(dek []byte) string { + return base64.StdEncoding.EncodeToString(dek) +} + +func loadRootKEK(ctx context.Context) ([]byte, error) { + columns := dao.SysDictData.Columns() + value, err := dao.SysDictData.Ctx(ctx). + Fields(columns.Value). + Where(columns.Type, consts.ImInternalSecretDictType). + Where(columns.Label, consts.ImMessageRootKEKLabel). + Where(columns.Status, consts.StatusEnabled). + Value() + if err != nil { + return nil, gerror.Wrap(err, "读取消息根密钥失败") + } + if value.IsEmpty() { + return nil, gerror.New("消息根密钥未初始化") + } + kek, err := base64.StdEncoding.DecodeString(value.String()) + if err != nil || len(kek) != encrypt.AESGCMKeySize { + return nil, gerror.New("消息根密钥格式不正确") + } + return kek, nil +} + +func keyAAD(tenantID int64, version uint) []byte { + aad := make([]byte, 16) + binary.BigEndian.PutUint64(aad[:8], uint64(tenantID)) + binary.BigEndian.PutUint64(aad[8:], uint64(version)) + return aad +} diff --git a/server/internal/logic/sys/dict_data.go b/server/internal/logic/sys/dict_data.go index fe5b9d9..c85fad9 100644 --- a/server/internal/logic/sys/dict_data.go +++ b/server/internal/logic/sys/dict_data.go @@ -30,6 +30,11 @@ func init() { // Delete 删除 func (s *sSysDictData) Delete(ctx context.Context, in *sysin.DictDataDeleteInp) error { + if count, err := dao.SysDictData.Ctx(ctx).WherePri(in.Id).Where(dao.SysDictData.Columns().Type, consts.ImInternalSecretDictType).Count(); err != nil { + return gerror.Wrap(err, consts.ErrorORM) + } else if count > 0 { + return gerror.New("内部密钥字典禁止通过通用接口操作") + } _, err := dao.SysDictData.Ctx(ctx).Where("id", in.Id).Delete() if err != nil { err = gerror.Wrap(err, consts.ErrorORM) @@ -42,6 +47,11 @@ func (s *sSysDictData) Delete(ctx context.Context, in *sysin.DictDataDeleteInp) func (s *sSysDictData) Edit(ctx context.Context, in *sysin.DictDataEditInp) (err error) { // 修改 if in.Id > 0 { + if count, queryErr := dao.SysDictData.Ctx(ctx).WherePri(in.Id).Where(dao.SysDictData.Columns().Type, consts.ImInternalSecretDictType).Count(); queryErr != nil { + return gerror.Wrap(queryErr, consts.ErrorORM) + } else if count > 0 { + return gerror.New("内部密钥字典禁止通过通用接口操作") + } _, err = dao.SysDictData.Ctx(ctx).Fields(sysin.DictDataUpdateFields{}).WherePri(in.Id).Data(in).Update() if err != nil { err = gerror.Wrap(err, consts.ErrorORM) @@ -60,6 +70,9 @@ func (s *sSysDictData) Edit(ctx context.Context, in *sysin.DictDataEditInp) (err if in.Type == "" { return gerror.Wrap(err, "类型选择无效,请检查") } + if in.Type == consts.ImInternalSecretDictType { + return gerror.New("内部密钥字典禁止通过通用接口操作") + } _, err = dao.SysDictData.Ctx(ctx).Fields(sysin.DictDataInsertFields{}).Data(in).OmitEmptyData().Insert() if err != nil { @@ -71,7 +84,7 @@ func (s *sSysDictData) Edit(ctx context.Context, in *sysin.DictDataEditInp) (err // List 获取列表 func (s *sSysDictData) List(ctx context.Context, in *sysin.DictDataListInp) (list []*sysin.DictDataListModel, totalCount int, err error) { - mod := dao.SysDictData.Ctx(ctx) + mod := dao.SysDictData.Ctx(ctx).WhereNot(dao.SysDictData.Columns().Type, consts.ImInternalSecretDictType) // 类型ID if in.TypeID > 0 { types, err := s.GetTypes(ctx, in.TypeID) @@ -151,6 +164,9 @@ func (s *sSysDictData) GetTypes(ctx context.Context, id int64) (types []string, // Select 获取列表 func (s *sSysDictData) Select(ctx context.Context, in *sysin.DataSelectInp) (list sysin.DataSelectModel, err error) { + if in.Type == consts.ImInternalSecretDictType { + return nil, gerror.New("内部密钥字典禁止通过通用接口读取") + } // 优先从内置字典中获取 options, err := dict.GetOptions(ctx, in.Type) if err == nil { diff --git a/server/internal/logic/sys/dict_type.go b/server/internal/logic/sys/dict_type.go index c45154d..3b8811f 100644 --- a/server/internal/logic/sys/dict_type.go +++ b/server/internal/logic/sys/dict_type.go @@ -31,7 +31,7 @@ func init() { // Tree 树 func (s *sSysDictType) Tree(ctx context.Context) (list []*sysin.DictTypeTree, err error) { var ( - mod = dao.SysDictType.Ctx(ctx) + mod = dao.SysDictType.Ctx(ctx).WhereNot(dao.SysDictType.Columns().Type, consts.ImInternalSecretDictType) models []*entity.SysDictType ) @@ -55,6 +55,9 @@ func (s *sSysDictType) Delete(ctx context.Context, in *sysin.DictTypeDeleteInp) err = gerror.New("数据不存在或已删除!") return } + if models.Type == consts.ImInternalSecretDictType { + return gerror.New("内部密钥字典禁止通过通用接口操作") + } exist, err := dao.SysDictData.Ctx(ctx).Where("type", models.Type).One() if err != nil { @@ -83,6 +86,18 @@ func (s *sSysDictType) Delete(ctx context.Context, in *sysin.DictTypeDeleteInp) // Edit 修改/新增 func (s *sSysDictType) Edit(ctx context.Context, in *sysin.DictTypeEditInp) (err error) { + if in.Type == consts.ImInternalSecretDictType { + return gerror.New("内部密钥字典禁止通过通用接口操作") + } + if in.Id > 0 { + count, queryErr := dao.SysDictType.Ctx(ctx).WherePri(in.Id).Where(dao.SysDictType.Columns().Type, consts.ImInternalSecretDictType).Count() + if queryErr != nil { + return gerror.Wrap(queryErr, consts.ErrorORM) + } + if count > 0 { + return gerror.New("内部密钥字典禁止通过通用接口操作") + } + } if err = hgorm.IsUnique(ctx, &dao.SysDictType, g.Map{dao.SysDictType.Columns().Name: in.Name}, "名称已存在", in.Id); err != nil { return } @@ -105,7 +120,7 @@ func (s *sSysDictType) Edit(ctx context.Context, in *sysin.DictTypeEditInp) (err // TreeSelect 获取类型关系树选项 func (s *sSysDictType) TreeSelect(ctx context.Context, in *sysin.DictTreeSelectInp) (list []*sysin.DictTypeTree, err error) { var ( - mod = dao.SysDictType.Ctx(ctx) + mod = dao.SysDictType.Ctx(ctx).WhereNot(dao.SysDictType.Columns().Type, consts.ImInternalSecretDictType) models []*entity.SysDictType ) diff --git a/server/internal/logic/sys/im_message_encryption.go b/server/internal/logic/sys/im_message_encryption.go new file mode 100644 index 0000000..e0f5710 --- /dev/null +++ b/server/internal/logic/sys/im_message_encryption.go @@ -0,0 +1,298 @@ +package sys + +import ( + "context" + "hotgo/internal/consts" + "hotgo/internal/dao" + "hotgo/internal/library/contexts" + "hotgo/internal/library/immessagecrypto" + "hotgo/internal/library/imtenant" + "hotgo/internal/model/entity" + "hotgo/internal/model/input/sysin" + "hotgo/internal/service" + "strings" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +const ( + imMessageEncryptionKeyTable = "im_message_encryption_keys" + imTenantMessageEncryptionColumn = "message_encryption_enabled" +) + +type sSysImMessageEncryption struct{} + +func NewSysImMessageEncryption() *sSysImMessageEncryption { return &sSysImMessageEncryption{} } + +func init() { + service.RegisterSysImMessageEncryption(NewSysImMessageEncryption()) +} + +func (s *sSysImMessageEncryption) List(ctx context.Context, in *sysin.ImMessageEncryptionListInp) (list []*sysin.ImMessageEncryptionListModel, total int, err error) { + if err = in.Filter(ctx); err != nil { + return + } + if err = s.requireSuperAdmin(ctx); err != nil { + return nil, 0, err + } + db := imtenant.RegistryDB() + mod := db.Model(dao.ImTenant.Table()+" t").Safe().Ctx(ctx). + LeftJoin(dao.ImTenantConfig.Table()+" c", "c.tenant_id=t.id"). + Fields( + "t.id tenant_id", "t.code tenant_code", "t.name tenant_name", + "COALESCE(c."+imTenantMessageEncryptionColumn+",0) message_encryption_enabled", + "COALESCE((SELECT MAX(k.key_version) FROM "+imMessageEncryptionKeyTable+" k WHERE k.tenant_id=t.id AND k.status='active'),0) active_version", + "COALESCE((SELECT MAX(k.key_version) FROM "+imMessageEncryptionKeyTable+" k WHERE k.tenant_id=t.id AND k.status='pending'),0) pending_version", + "(SELECT COUNT(1) FROM "+imMessageEncryptionKeyTable+" k WHERE k.tenant_id=t.id) key_count", + "(SELECT MAX(COALESCE(k.activated_at,k.created_at)) FROM "+imMessageEncryptionKeyTable+" k WHERE k.tenant_id=t.id) last_rotated_at", + ) + if in.TenantId > 0 { + mod = mod.Where("t.id", in.TenantId) + } + if keyword := strings.TrimSpace(in.Keyword); keyword != "" { + mod = mod.Where("t.code LIKE ? OR t.name LIKE ?", "%"+keyword+"%", "%"+keyword+"%") + } + if in.Enabled != nil { + mod = mod.Where("COALESCE(c."+imTenantMessageEncryptionColumn+",0)=?", *in.Enabled) + } + if err = mod.Page(in.Page, in.PerPage).OrderAsc("t.id").ScanAndCount(&list, &total, false); err != nil { + return nil, 0, gerror.Wrap(err, "获取商户消息加密配置失败") + } + return +} + +func (s *sSysImMessageEncryption) Initialize(ctx context.Context, in *sysin.ImMessageEncryptionActionInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + if err := s.requireSuperAdmin(ctx); err != nil { + return err + } + tenant, err := s.tenantByID(ctx, in.TenantId) + if err != nil { + return err + } + dek, err := immessagecrypto.GenerateDEK() + if err != nil { + return err + } + ciphertext, nonce, err := immessagecrypto.WrapDEK(ctx, tenant.Id, 1, dek) + if err != nil { + return err + } + err = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + count, queryErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).Where("tenant_id", tenant.Id).Count() + if queryErr != nil { + return gerror.Wrap(queryErr, "检查商户消息密钥失败") + } + if count > 0 { + return gerror.New("该商户已经初始化消息密钥") + } + _, insertErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).Data(g.Map{ + "tenant_id": tenant.Id, "key_version": 1, "algorithm": consts.ImMessageKeyAlgorithm, + "encrypted_dek": ciphertext, "dek_nonce": nonce, "status": consts.ImMessageKeyStatusActive, + "created_at": gtime.Now(), "activated_at": gtime.Now(), "created_by": contexts.GetUserId(ctx), + "remark": strings.TrimSpace(in.Remark), + }).Insert() + if insertErr != nil { + return gerror.Wrap(insertErr, "初始化商户消息密钥失败") + } + return nil + }) + if err != nil { + return err + } + _ = writeImAdminAudit(ctx, tenant.Id, "message_key_initialize", imMessageEncryptionKeyTable, 0, "success", g.Map{"version": 1}) + return publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "message.keys.updated", "message_encryption", 1) +} + +func (s *sSysImMessageEncryption) Rotate(ctx context.Context, in *sysin.ImMessageEncryptionActionInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + if err := s.requireSuperAdmin(ctx); err != nil { + return err + } + tenant, err := s.tenantByID(ctx, in.TenantId) + if err != nil { + return err + } + var version uint + err = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + pending, queryErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Where("tenant_id", tenant.Id).Where("status", consts.ImMessageKeyStatusPending).Count() + if queryErr != nil { + return gerror.Wrap(queryErr, "检查待激活消息密钥失败") + } + if pending > 0 { + return gerror.New("该商户已有待激活密钥,请等待IM Server确认") + } + value, queryErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Fields("COALESCE(MAX(key_version),0)").Where("tenant_id", tenant.Id).Value() + if queryErr != nil { + return gerror.Wrap(queryErr, "获取消息密钥版本失败") + } + if value.Uint() == 0 { + return gerror.New("请先初始化商户消息密钥") + } + version = value.Uint() + 1 + dek, generateErr := immessagecrypto.GenerateDEK() + if generateErr != nil { + return generateErr + } + ciphertext, nonce, wrapErr := immessagecrypto.WrapDEK(ctx, tenant.Id, version, dek) + if wrapErr != nil { + return wrapErr + } + _, insertErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).Data(g.Map{ + "tenant_id": tenant.Id, "key_version": version, "algorithm": consts.ImMessageKeyAlgorithm, + "encrypted_dek": ciphertext, "dek_nonce": nonce, "status": consts.ImMessageKeyStatusPending, + "created_at": gtime.Now(), "created_by": contexts.GetUserId(ctx), "remark": strings.TrimSpace(in.Remark), + }).Insert() + if insertErr != nil { + return gerror.Wrap(insertErr, "创建待激活消息密钥失败") + } + return nil + }) + if err != nil { + return err + } + _ = writeImAdminAudit(ctx, tenant.Id, "message_key_rotate", imMessageEncryptionKeyTable, 0, "success", g.Map{"version": version, "status": consts.ImMessageKeyStatusPending}) + return publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "message.keys.updated", "message_encryption", version) +} + +func (s *sSysImMessageEncryption) Switch(ctx context.Context, in *sysin.ImMessageEncryptionSwitchInp) error { + if err := in.Filter(ctx); err != nil { + return err + } + if err := s.requireSuperAdmin(ctx); err != nil { + return err + } + tenant, err := s.tenantByID(ctx, in.TenantId) + if err != nil { + return err + } + if in.Enabled == 1 { + count, queryErr := imtenant.RegistryDB().Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Where("tenant_id", tenant.Id).Where("status", consts.ImMessageKeyStatusActive).Count() + if queryErr != nil { + return gerror.Wrap(queryErr, "检查商户消息密钥失败") + } + if count == 0 { + return gerror.New("启用消息加密前必须先初始化有效密钥") + } + } + result, err := imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx). + Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id). + Data(g.Map{imTenantMessageEncryptionColumn: in.Enabled, dao.ImTenantConfig.Columns().UpdatedBy: contexts.GetUserId(ctx)}).Update() + if err != nil { + return gerror.Wrap(err, "更新商户消息加密开关失败") + } + if affected, _ := result.RowsAffected(); affected == 0 { + return gerror.New("商户配置不存在") + } + _ = writeImAdminAudit(ctx, tenant.Id, "message_encryption_switch", "im_tenant_config", tenant.Id, "success", g.Map{"enabled": in.Enabled}) + return publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "tenant.features.updated", "message_encryption", 0) +} + +func (s *sSysImMessageEncryption) GetKey(ctx context.Context, in *sysin.ImMessageKeyGetInp) (*sysin.ImMessageKeyModel, error) { + tenant, err := s.tenantByCode(ctx, in.TenantCode) + if err != nil { + return nil, err + } + enabledValue, err := imtenant.RegistryDB().Model(dao.ImTenantConfig.Table()).Safe().Ctx(ctx). + Fields(imTenantMessageEncryptionColumn).Where(dao.ImTenantConfig.Columns().TenantId, tenant.Id).Value() + if err != nil { + return nil, gerror.Wrap(err, "获取商户消息加密开关失败") + } + mod := imtenant.RegistryDB().Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx).Where("tenant_id", tenant.Id) + if in.Version > 0 { + mod = mod.Where("key_version", in.Version).WhereIn("status", []string{consts.ImMessageKeyStatusPending, consts.ImMessageKeyStatusActive, consts.ImMessageKeyStatusRetired}) + } else { + mod = mod.Where("status", consts.ImMessageKeyStatusActive).OrderDesc("key_version") + } + row, err := mod.One() + if err != nil { + return nil, gerror.Wrap(err, "获取商户消息密钥失败") + } + if row.IsEmpty() { + return nil, gerror.New("商户消息密钥不存在") + } + version := row["key_version"].Uint() + dek, err := immessagecrypto.UnwrapDEK(ctx, tenant.Id, version, row["encrypted_dek"].Bytes(), row["dek_nonce"].Bytes()) + if err != nil { + return nil, err + } + return &sysin.ImMessageKeyModel{ + TenantId: tenant.Id, TenantCode: tenant.Code, Enabled: enabledValue.Int() == 1, + Version: version, Algorithm: row["algorithm"].String(), Status: row["status"].String(), Key: immessagecrypto.EncodeDEK(dek), + }, nil +} + +func (s *sSysImMessageEncryption) Ack(ctx context.Context, in *sysin.ImMessageKeyAckInp) error { + tenant, err := s.tenantByCode(ctx, in.TenantCode) + if err != nil { + return err + } + if in.Version == 0 { + return gerror.New("消息密钥版本不能为空") + } + err = imtenant.RegistryDB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + row, queryErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Where("tenant_id", tenant.Id).Where("key_version", in.Version).One() + if queryErr != nil { + return gerror.Wrap(queryErr, "获取待激活消息密钥失败") + } + if row.IsEmpty() || row["status"].String() != consts.ImMessageKeyStatusPending { + return gerror.New("待激活消息密钥不存在") + } + if _, updateErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Where("tenant_id", tenant.Id).Where("status", consts.ImMessageKeyStatusActive). + Data(g.Map{"status": consts.ImMessageKeyStatusRetired, "retired_at": gtime.Now()}).Update(); updateErr != nil { + return gerror.Wrap(updateErr, "退役旧消息密钥失败") + } + if _, updateErr := tx.Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Where("tenant_id", tenant.Id).Where("key_version", in.Version). + Data(g.Map{"status": consts.ImMessageKeyStatusActive, "activated_at": gtime.Now()}).Update(); updateErr != nil { + return gerror.Wrap(updateErr, "激活新消息密钥失败") + } + return nil + }) + if err != nil { + return err + } + return publishImPolicyEvent(ctx, tenant.Id, tenant.Code, "message.keys.activated", "message_encryption", in.Version) +} + +func (s *sSysImMessageEncryption) requireSuperAdmin(ctx context.Context) error { + if !service.AdminMember().VerifySuperId(ctx, contexts.GetUserId(ctx)) { + return gerror.New("只有最高权限平台管理员可以管理消息密钥") + } + return nil +} + +func (s *sSysImMessageEncryption) tenantByID(ctx context.Context, tenantID int64) (*entity.ImTenant, error) { + var tenant *entity.ImTenant + if err := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx).WherePri(tenantID).Scan(&tenant); err != nil { + return nil, gerror.Wrap(err, "获取商户失败") + } + if tenant == nil || tenant.Id == 0 { + return nil, gerror.New("商户不存在") + } + return tenant, nil +} + +func (s *sSysImMessageEncryption) tenantByCode(ctx context.Context, code string) (*entity.ImTenant, error) { + var tenant *entity.ImTenant + if err := imtenant.RegistryDB().Model(dao.ImTenant.Table()).Safe().Ctx(ctx). + Where(dao.ImTenant.Columns().Code, strings.TrimSpace(code)).Scan(&tenant); err != nil { + return nil, gerror.Wrap(err, "获取商户失败") + } + if tenant == nil || tenant.Id == 0 { + return nil, gerror.New("商户不存在") + } + return tenant, nil +} diff --git a/server/internal/logic/sys/im_tenant.go b/server/internal/logic/sys/im_tenant.go index aa3bc19..6479886 100644 --- a/server/internal/logic/sys/im_tenant.go +++ b/server/internal/logic/sys/im_tenant.go @@ -26,20 +26,22 @@ import ( type sSysImTenant struct{} const ( - tenantConfigAliyunModerationEnabled = "aliyun_moderation_enabled" - tenantConfigLocalModerationEnabled = "local_moderation_enabled" - tenantConfigRealnameReviewPolicy = "realname_review_policy" + tenantConfigAliyunModerationEnabled = "aliyun_moderation_enabled" + tenantConfigLocalModerationEnabled = "local_moderation_enabled" + tenantConfigMessageEncryptionEnabled = "message_encryption_enabled" + tenantConfigRealnameReviewPolicy = "realname_review_policy" ) type imTenantConfigModeration struct { - Id int64 `json:"id" orm:"id"` - TenantId int64 `json:"tenant_id" orm:"tenant_id"` - MaxUsers uint64 `json:"max_users" orm:"max_users"` - MaxGroups uint64 `json:"max_groups" orm:"max_groups"` - MaxGroupMembers uint `json:"max_group_members" orm:"max_group_members"` - AliyunModerationEnabled int `json:"aliyun_moderation_enabled" orm:"aliyun_moderation_enabled"` - LocalModerationEnabled int `json:"local_moderation_enabled" orm:"local_moderation_enabled"` - RealnameReviewPolicy int `json:"realname_review_policy" orm:"realname_review_policy"` + Id int64 `json:"id" orm:"id"` + TenantId int64 `json:"tenant_id" orm:"tenant_id"` + MaxUsers uint64 `json:"max_users" orm:"max_users"` + MaxGroups uint64 `json:"max_groups" orm:"max_groups"` + MaxGroupMembers uint `json:"max_group_members" orm:"max_group_members"` + AliyunModerationEnabled int `json:"aliyun_moderation_enabled" orm:"aliyun_moderation_enabled"` + LocalModerationEnabled int `json:"local_moderation_enabled" orm:"local_moderation_enabled"` + MessageEncryptionEnabled int `json:"message_encryption_enabled" orm:"message_encryption_enabled"` + RealnameReviewPolicy int `json:"realname_review_policy" orm:"realname_review_policy"` } func NewSysImTenant() *sSysImTenant { @@ -159,6 +161,7 @@ func (s *sSysImTenant) fillListConfig(ctx context.Context, list []*sysin.ImTenan item.MaxGroupMembers = conf.MaxGroupMembers item.AliyunModerationEnabled = conf.AliyunModerationEnabled item.LocalModerationEnabled = conf.LocalModerationEnabled + item.MessageEncryptionEnabled = conf.MessageEncryptionEnabled item.RealnameReviewPolicy = conf.RealnameReviewPolicy } item.DbDsnConfigured = dsnMap[item.Id] != "" @@ -251,6 +254,18 @@ func (s *sSysImTenant) checkCodeUnique(ctx context.Context, id int64, code strin } func (s *sSysImTenant) saveConfig(ctx context.Context, in *sysin.ImTenantEditInp, userId int64) (err error) { + if in.MessageEncryptionEnabled == 1 { + count, queryErr := imtenant.RegistryDB().Model(imMessageEncryptionKeyTable).Safe().Ctx(ctx). + Where("tenant_id", in.Id). + Where("status", consts.ImMessageKeyStatusActive). + Count() + if queryErr != nil { + return gerror.Wrap(queryErr, "检查商户消息密钥失败") + } + if count == 0 { + return gerror.New("启用消息加密前必须先在消息密钥管理中初始化有效密钥") + } + } var conf *entity.ImTenantConfig if err = s.configModel(ctx). Where(dao.ImTenantConfig.Columns().TenantId, in.Id). @@ -264,6 +279,7 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, in *sysin.ImTenantEditInp dao.ImTenantConfig.Columns().MaxGroupMembers: in.MaxGroupMembers, tenantConfigAliyunModerationEnabled: in.AliyunModerationEnabled, tenantConfigLocalModerationEnabled: in.LocalModerationEnabled, + tenantConfigMessageEncryptionEnabled: in.MessageEncryptionEnabled, tenantConfigRealnameReviewPolicy: in.RealnameReviewPolicy, } if conf != nil && conf.Id > 0 { @@ -275,6 +291,7 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, in *sysin.ImTenantEditInp dao.ImTenantConfig.Columns().MaxGroupMembers, tenantConfigAliyunModerationEnabled, tenantConfigLocalModerationEnabled, + tenantConfigMessageEncryptionEnabled, tenantConfigRealnameReviewPolicy, dao.ImTenantConfig.Columns().UpdatedBy, ). @@ -295,6 +312,7 @@ func (s *sSysImTenant) saveConfig(ctx context.Context, in *sysin.ImTenantEditInp dao.ImTenantConfig.Columns().MaxGroupMembers, tenantConfigAliyunModerationEnabled, tenantConfigLocalModerationEnabled, + tenantConfigMessageEncryptionEnabled, tenantConfigRealnameReviewPolicy, dao.ImTenantConfig.Columns().CreatedBy, dao.ImTenantConfig.Columns().UpdatedBy, @@ -331,6 +349,7 @@ func (s *sSysImTenant) View(ctx context.Context, in *sysin.ImTenantViewInp) (res res.MaxGroupMembers = conf.MaxGroupMembers res.AliyunModerationEnabled = conf.AliyunModerationEnabled res.LocalModerationEnabled = conf.LocalModerationEnabled + res.MessageEncryptionEnabled = conf.MessageEncryptionEnabled res.RealnameReviewPolicy = conf.RealnameReviewPolicy } res.DbDsnConfigured = res.DbDsn != "" diff --git a/server/internal/model/input/sysin/im_message_encryption.go b/server/internal/model/input/sysin/im_message_encryption.go new file mode 100644 index 0000000..5aacd01 --- /dev/null +++ b/server/internal/model/input/sysin/im_message_encryption.go @@ -0,0 +1,78 @@ +package sysin + +import ( + "context" + "hotgo/internal/model/input/form" + + "github.com/gogf/gf/v2/errors/gerror" + "github.com/gogf/gf/v2/os/gtime" +) + +type ImMessageEncryptionListInp struct { + form.PageReq + TenantId int64 `json:"tenantId" dc:"商户ID"` + Keyword string `json:"keyword" dc:"商户编码或名称"` + Enabled *int `json:"enabled" dc:"消息加密开关"` +} + +func (in *ImMessageEncryptionListInp) Filter(ctx context.Context) error { + return nil +} + +type ImMessageEncryptionListModel struct { + TenantId int64 `json:"tenantId" dc:"商户ID"` + TenantCode string `json:"tenantCode" dc:"商户编码"` + TenantName string `json:"tenantName" dc:"商户名称"` + MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"` + ActiveVersion uint `json:"activeVersion" dc:"当前密钥版本"` + PendingVersion uint `json:"pendingVersion" dc:"待激活密钥版本"` + KeyCount int `json:"keyCount" dc:"密钥版本数"` + LastRotatedAt *gtime.Time `json:"lastRotatedAt" dc:"最后轮换时间"` +} + +type ImMessageEncryptionActionInp struct { + TenantId int64 `json:"tenantId" v:"required|min:1#请选择商户|商户不正确" dc:"商户ID"` + Remark string `json:"remark" dc:"操作备注"` +} + +func (in *ImMessageEncryptionActionInp) Filter(ctx context.Context) error { + if in.TenantId <= 0 { + return gerror.New("请选择商户") + } + return nil +} + +type ImMessageEncryptionSwitchInp struct { + TenantId int64 `json:"tenantId" v:"required|min:1#请选择商户|商户不正确" dc:"商户ID"` + Enabled int `json:"enabled" dc:"是否启用"` +} + +func (in *ImMessageEncryptionSwitchInp) Filter(ctx context.Context) error { + if in.TenantId <= 0 { + return gerror.New("请选择商户") + } + if in.Enabled != 0 && in.Enabled != 1 { + return gerror.New("消息加密开关值不正确") + } + return nil +} + +type ImMessageKeyGetInp struct { + TenantCode string `json:"tenantCode" dc:"商户编码"` + Version uint `json:"version" dc:"密钥版本,0表示当前版本"` +} + +type ImMessageKeyModel struct { + TenantId int64 `json:"tenantId" dc:"商户ID"` + TenantCode string `json:"tenantCode" dc:"商户编码"` + Enabled bool `json:"enabled" dc:"消息加密是否启用"` + Version uint `json:"version" dc:"密钥版本"` + Algorithm string `json:"algorithm" dc:"算法"` + Status string `json:"status" dc:"密钥状态"` + Key string `json:"key" dc:"Base64编码DEK,仅内部接口返回"` +} + +type ImMessageKeyAckInp struct { + TenantCode string `json:"tenantCode" dc:"商户编码"` + Version uint `json:"version" dc:"密钥版本"` +} diff --git a/server/internal/model/input/sysin/im_tenant.go b/server/internal/model/input/sysin/im_tenant.go index 69a350c..b94d20c 100644 --- a/server/internal/model/input/sysin/im_tenant.go +++ b/server/internal/model/input/sysin/im_tenant.go @@ -43,36 +43,39 @@ type ImTenantInsertFields struct { // ImTenantConfigUpdateFields 修改IM租户配置字段过滤 type ImTenantConfigUpdateFields struct { - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` - LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` - RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` - UpdatedBy int64 `json:"updatedBy" dc:"更新者"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` + UpdatedBy int64 `json:"updatedBy" dc:"更新者"` } // ImTenantConfigInsertFields 新增IM租户配置字段过滤 type ImTenantConfigInsertFields struct { - TenantId int64 `json:"tenantId" dc:"租户ID"` - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` - LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` - RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` - CreatedBy int64 `json:"createdBy" dc:"创建者"` + TenantId int64 `json:"tenantId" dc:"租户ID"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` + CreatedBy int64 `json:"createdBy" dc:"创建者"` } // ImTenantEditInp 修改/新增IM租户 type ImTenantEditInp struct { entity.ImTenant - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` - LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` - RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` } func (in *ImTenantEditInp) Filter(ctx context.Context) (err error) { @@ -89,8 +92,9 @@ func (in *ImTenantEditInp) Filter(ctx context.Context) (err error) { return gerror.New("租户状态不正确") } if (in.AliyunModerationEnabled != 0 && in.AliyunModerationEnabled != 1) || - (in.LocalModerationEnabled != 0 && in.LocalModerationEnabled != 1) { - return gerror.New("内容审核开关值不正确") + (in.LocalModerationEnabled != 0 && in.LocalModerationEnabled != 1) || + (in.MessageEncryptionEnabled != 0 && in.MessageEncryptionEnabled != 1) { + return gerror.New("商户功能开关值不正确") } if in.RealnameReviewPolicy != 0 && in.RealnameReviewPolicy != 1 { return gerror.New("实名审核策略不正确") @@ -111,17 +115,18 @@ func (in *ImTenantViewInp) Filter(ctx context.Context) (err error) { type ImTenantViewModel struct { entity.ImTenant - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` - LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` - RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` - CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` - UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` - DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` - TenantDbReachable bool `json:"tenantDbReachable" dc:"业务库是否可达"` - TenantDbCheckError string `json:"tenantDbCheckError" dc:"业务库检查错误"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` + CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` + UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` + DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` + TenantDbReachable bool `json:"tenantDbReachable" dc:"业务库是否可达"` + TenantDbCheckError string `json:"tenantDbCheckError" dc:"业务库检查错误"` } // ImTenantListInp 获取IM租户列表 @@ -140,25 +145,26 @@ func (in *ImTenantListInp) Filter(ctx context.Context) (err error) { } type ImTenantListModel struct { - Id int64 `json:"id" dc:"ID"` - Code string `json:"code" dc:"租户编码"` - Name string `json:"name" dc:"租户名称"` - TenantDesc string `json:"tenantDesc" dc:"租户描述"` - DbName string `json:"dbName" dc:"业务库名"` - State int `json:"state" dc:"状态"` - MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` - MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` - MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` - AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` - LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` - RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` - DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` - CreatedBy int64 `json:"createdBy" dc:"创建者"` - CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` - UpdatedBy int64 `json:"updatedBy" dc:"更新者"` - UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` - CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` - UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` + Id int64 `json:"id" dc:"ID"` + Code string `json:"code" dc:"租户编码"` + Name string `json:"name" dc:"租户名称"` + TenantDesc string `json:"tenantDesc" dc:"租户描述"` + DbName string `json:"dbName" dc:"业务库名"` + State int `json:"state" dc:"状态"` + MaxUsers uint64 `json:"maxUsers" dc:"最大用户数"` + MaxGroups uint64 `json:"maxGroups" dc:"最大群组数"` + MaxGroupMembers uint `json:"maxGroupMembers" dc:"最大群成员数"` + AliyunModerationEnabled int `json:"aliyunModerationEnabled" dc:"是否启用阿里云内容审核"` + LocalModerationEnabled int `json:"localModerationEnabled" dc:"是否启用本地内容审核"` + MessageEncryptionEnabled int `json:"messageEncryptionEnabled" dc:"是否启用消息加密"` + RealnameReviewPolicy int `json:"realnameReviewPolicy" dc:"实名审核策略"` + DbDsnConfigured bool `json:"dbDsnConfigured" dc:"是否配置独立连接串"` + CreatedBy int64 `json:"createdBy" dc:"创建者"` + CreatedBySumma *hook.MemberSumma `json:"createdBySumma" dc:"创建者摘要信息"` + UpdatedBy int64 `json:"updatedBy" dc:"更新者"` + UpdatedBySumma *hook.MemberSumma `json:"updatedBySumma" dc:"更新者摘要信息"` + CreatedAt *gtime.Time `json:"createdAt" dc:"创建时间"` + UpdatedAt *gtime.Time `json:"updatedAt" dc:"修改时间"` } // ImTenantStatusInp 更新IM租户状态 diff --git a/server/internal/router/genrouter/im_message_encryption.go b/server/internal/router/genrouter/im_message_encryption.go new file mode 100644 index 0000000..84525d7 --- /dev/null +++ b/server/internal/router/genrouter/im_message_encryption.go @@ -0,0 +1,7 @@ +package genrouter + +import "hotgo/internal/controller/admin/sys" + +func init() { + LoginRequiredRouter = append(LoginRequiredRouter, sys.ImMessageEncryption) +} diff --git a/server/internal/service/sys.go b/server/internal/service/sys.go index da72af2..55de95a 100644 --- a/server/internal/service/sys.go +++ b/server/internal/service/sys.go @@ -211,6 +211,14 @@ type ( // Restore 恢复IM租户 Restore(ctx context.Context, in *sysin.ImTenantLifecycleInp) (err error) } + ISysImMessageEncryption interface { + List(ctx context.Context, in *sysin.ImMessageEncryptionListInp) (list []*sysin.ImMessageEncryptionListModel, total int, err error) + Initialize(ctx context.Context, in *sysin.ImMessageEncryptionActionInp) error + Rotate(ctx context.Context, in *sysin.ImMessageEncryptionActionInp) error + Switch(ctx context.Context, in *sysin.ImMessageEncryptionSwitchInp) error + GetKey(ctx context.Context, in *sysin.ImMessageKeyGetInp) (*sysin.ImMessageKeyModel, error) + Ack(ctx context.Context, in *sysin.ImMessageKeyAckInp) error + } ISysImRegistration interface { // GetConfig 获取IM注册配置 GetConfig(ctx context.Context) (res *sysin.ImRegistrationConfigModel, err error) @@ -779,60 +787,61 @@ type ( ) var ( - localSysAddons ISysAddons - localSysAddonsConfig ISysAddonsConfig - localSysAttachment ISysAttachment - localSysBlacklist ISysBlacklist - localSysConfig ISysConfig - localSysCron ISysCron - localSysCronGroup ISysCronGroup - localSysCurdDemo ISysCurdDemo - localSysCultivateFlower ISysCultivateFlower - localSysCultivateContent ISysCultivateContent - localSysImTenant ISysImTenant - localSysImRegistration ISysImRegistration - localSysImInvite ISysImInvite - localSysImUser ISysImUser - localSysImGroup ISysImGroup - localSysImRealname ISysImRealname - localSysImWorkOrder ISysImWorkOrder - localSysImOutbox ISysImOutbox - localSysImOrg ISysImOrg - localSysImFile ISysImFile - localSysImFilePolicy ISysImFilePolicy - localSysImUserPolicyGroup ISysImUserPolicyGroup - localSysImSensitiveWord ISysImSensitiveWord - localSysImSecurityPolicy ISysImSecurityPolicy - localSysImDashboard ISysImDashboard - localSysImPrivateMessage ISysImPrivateMessage - localSysImGroupMessage ISysImGroupMessage - localSysImUserLoginHistory ISysImUserLoginHistory - localSysImLoginFailureLog ISysImLoginFailureLog - localSysImExternalConfig ISysImExternalConfig - localSysImNotification ISysImNotification - localSysImAppRelease ISysImAppRelease - localSysImAbuseReason ISysImAbuseReason - localSysImMailboxConfig ISysImMailboxConfig - localSysImFeedbackConfig ISysImFeedbackConfig - localSysImWorkbench ISysImWorkbench - localSysImTaskDashboard ISysImTaskDashboard - localSysImAuditLog ISysImAuditLog - localSysImKafkaAudit ISysImKafkaAudit - localSysImKafkaCommand ISysImKafkaCommand - localSysImDeadLetter ISysImDeadLetter - localSysDictData ISysDictData - localSysDictType ISysDictType - localSysEmsLog ISysEmsLog - localSysGenCodes ISysGenCodes - localSysLog ISysLog - localSysLoginLog ISysLoginLog - localSysNormalTreeDemo ISysNormalTreeDemo - localSysOptionTreeDemo ISysOptionTreeDemo - localSysProvinces ISysProvinces - localSysServeLicense ISysServeLicense - localSysServeLog ISysServeLog - localSysSmsLog ISysSmsLog - localSysTestCategory ISysTestCategory + localSysAddons ISysAddons + localSysAddonsConfig ISysAddonsConfig + localSysAttachment ISysAttachment + localSysBlacklist ISysBlacklist + localSysConfig ISysConfig + localSysCron ISysCron + localSysCronGroup ISysCronGroup + localSysCurdDemo ISysCurdDemo + localSysCultivateFlower ISysCultivateFlower + localSysCultivateContent ISysCultivateContent + localSysImTenant ISysImTenant + localSysImMessageEncryption ISysImMessageEncryption + localSysImRegistration ISysImRegistration + localSysImInvite ISysImInvite + localSysImUser ISysImUser + localSysImGroup ISysImGroup + localSysImRealname ISysImRealname + localSysImWorkOrder ISysImWorkOrder + localSysImOutbox ISysImOutbox + localSysImOrg ISysImOrg + localSysImFile ISysImFile + localSysImFilePolicy ISysImFilePolicy + localSysImUserPolicyGroup ISysImUserPolicyGroup + localSysImSensitiveWord ISysImSensitiveWord + localSysImSecurityPolicy ISysImSecurityPolicy + localSysImDashboard ISysImDashboard + localSysImPrivateMessage ISysImPrivateMessage + localSysImGroupMessage ISysImGroupMessage + localSysImUserLoginHistory ISysImUserLoginHistory + localSysImLoginFailureLog ISysImLoginFailureLog + localSysImExternalConfig ISysImExternalConfig + localSysImNotification ISysImNotification + localSysImAppRelease ISysImAppRelease + localSysImAbuseReason ISysImAbuseReason + localSysImMailboxConfig ISysImMailboxConfig + localSysImFeedbackConfig ISysImFeedbackConfig + localSysImWorkbench ISysImWorkbench + localSysImTaskDashboard ISysImTaskDashboard + localSysImAuditLog ISysImAuditLog + localSysImKafkaAudit ISysImKafkaAudit + localSysImKafkaCommand ISysImKafkaCommand + localSysImDeadLetter ISysImDeadLetter + localSysDictData ISysDictData + localSysDictType ISysDictType + localSysEmsLog ISysEmsLog + localSysGenCodes ISysGenCodes + localSysLog ISysLog + localSysLoginLog ISysLoginLog + localSysNormalTreeDemo ISysNormalTreeDemo + localSysOptionTreeDemo ISysOptionTreeDemo + localSysProvinces ISysProvinces + localSysServeLicense ISysServeLicense + localSysServeLog ISysServeLog + localSysSmsLog ISysSmsLog + localSysTestCategory ISysTestCategory ) func SysAddons() ISysAddons { @@ -954,6 +963,17 @@ func RegisterSysImTenant(i ISysImTenant) { localSysImTenant = i } +func SysImMessageEncryption() ISysImMessageEncryption { + if localSysImMessageEncryption == nil { + panic("implement not found for interface ISysImMessageEncryption, forgot register?") + } + return localSysImMessageEncryption +} + +func RegisterSysImMessageEncryption(i ISysImMessageEncryption) { + localSysImMessageEncryption = i +} + func SysImRegistration() ISysImRegistration { if localSysImRegistration == nil { panic("implement not found for interface ISysImRegistration, forgot register?") diff --git a/server/storage/data/generate/im_message_encryption.sql b/server/storage/data/generate/im_message_encryption.sql new file mode 100644 index 0000000..219690d --- /dev/null +++ b/server/storage/data/generate/im_message_encryption.sql @@ -0,0 +1,137 @@ +-- 消息加密密钥管理。 +-- 可重复执行。根 KEK 只在首次执行时生成,重复执行不会覆盖已有值。 + +SET NAMES utf8mb4; +SET @now := NOW(); + +SET @im_sql = IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'im_tenant_config' + AND COLUMN_NAME = 'message_encryption_enabled') = 0, + 'ALTER TABLE `im_tenant_config` ADD COLUMN `message_encryption_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''是否启用消息加密'' AFTER `local_moderation_enabled`', + 'SELECT 1' +); +PREPARE im_stmt FROM @im_sql; +EXECUTE im_stmt; +DEALLOCATE PREPARE im_stmt; + +CREATE TABLE IF NOT EXISTS `im_message_encryption_keys` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` bigint NOT NULL COMMENT '商户ID', + `key_version` int unsigned NOT NULL COMMENT '密钥版本', + `algorithm` varchar(32) NOT NULL DEFAULT 'AES-256-GCM' COMMENT '数据加密算法', + `encrypted_dek` blob NOT NULL COMMENT '由根KEK包裹后的商户DEK', + `dek_nonce` varbinary(12) NOT NULL COMMENT '包裹DEK使用的随机nonce', + `status` varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/active/retired/disabled', + `activated_at` datetime DEFAULT NULL, + `retired_at` datetime DEFAULT NULL, + `remark` varchar(255) NOT NULL DEFAULT '', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `created_by` bigint NOT NULL DEFAULT 0, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `updated_by` bigint NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_message_key_tenant_version` (`tenant_id`, `key_version`), + KEY `idx_im_message_key_tenant_status` (`tenant_id`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='商户消息加密密钥版本'; + +INSERT INTO `hg_sys_dict_type` + (`pid`, `name`, `type`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 0, 'IM内部安全材料', 'im_internal_secret', 9999, + '仅供服务端读取,通用字典接口禁止查询和维护', 1, @now, @now +WHERE NOT EXISTS ( + SELECT 1 FROM `hg_sys_dict_type` WHERE `type` = 'im_internal_secret' +); + +INSERT INTO `hg_sys_dict_data` + (`label`, `value`, `value_type`, `type`, `list_class`, `is_default`, `sort`, `remark`, `status`, `created_at`, `updated_at`) +SELECT 'core_material_01', TO_BASE64(RANDOM_BYTES(32)), 'string', 'im_internal_secret', + '', 2, 1, '消息DEK包裹根密钥KEK,请勿通过后台展示或修改', 1, @now, @now +WHERE NOT EXISTS ( + SELECT 1 FROM `hg_sys_dict_data` + WHERE `type` = 'im_internal_secret' AND `label` = 'core_material_01' +); + +SET @imRootId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imManager' LIMIT 1); +SET @settingsId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imSystemSettings' LIMIT 1); + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @settingsId, '消息密钥管理', 'imMessageEncryptionIndex', 'message-encryption/index', + 'KeyOutlined', 2, '', '/imMessageEncryption/list', '', '/imMessageEncryption/index', + 1, 'imManager', 0, 0, '', 0, 0, 0, 3, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), 80, + '商户消息加密开关及密钥版本管理', 1, @now, @now +WHERE @settingsId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionIndex'); + +SET @keyMenuId := (SELECT `id` FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionIndex' LIMIT 1); + +UPDATE `hg_admin_menu` +SET `pid` = @settingsId, + `title` = '消息密钥管理', + `path` = 'message-encryption/index', + `icon` = 'KeyOutlined', + `permissions` = '/imMessageEncryption/list', + `component` = '/imMessageEncryption/index', + `active_menu` = 'imManager', + `level` = 3, + `tree` = CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' '), + `sort` = 80, + `hidden` = 0, + `status` = 1, + `updated_at` = @now +WHERE `id` = @keyMenuId; + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @keyMenuId, '初始化密钥', 'imMessageEncryptionInitialize', '', '', 3, '', + '/imMessageEncryption/initialize', '', '', 1, '', 0, 0, '', 0, 1, 0, 4, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 10, '', 1, @now, @now +WHERE @keyMenuId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionInitialize'); + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @keyMenuId, '轮换密钥', 'imMessageEncryptionRotate', '', '', 3, '', + '/imMessageEncryption/rotate', '', '', 1, '', 0, 0, '', 0, 1, 0, 4, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 20, '', 1, @now, @now +WHERE @keyMenuId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionRotate'); + +INSERT INTO `hg_admin_menu` + (`pid`, `title`, `name`, `path`, `icon`, `type`, `redirect`, `permissions`, + `permission_name`, `component`, `always_show`, `active_menu`, `is_root`, `is_frame`, + `frame_src`, `keep_alive`, `hidden`, `affix`, `level`, `tree`, `sort`, `remark`, + `status`, `created_at`, `updated_at`) +SELECT @keyMenuId, '切换消息加密', 'imMessageEncryptionSwitch', '', '', 3, '', + '/imMessageEncryption/switch', '', '', 1, '', 0, 0, '', 0, 1, 0, 4, + CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), 30, '', 1, @now, @now +WHERE @keyMenuId IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM `hg_admin_menu` WHERE `name` = 'imMessageEncryptionSwitch'); + +UPDATE `hg_admin_menu` +SET `level` = 4, + `tree` = CONCAT('tr_', @imRootId, ' tr_', @settingsId, ' tr_', @keyMenuId, ' '), + `updated_at` = @now +WHERE `pid` = @keyMenuId AND `type` = 3; + +INSERT IGNORE INTO `hg_admin_role_menu` (`role_id`, `menu_id`) +SELECT 1, `id` FROM `hg_admin_menu` +WHERE `name` IN ( + 'imManager', + 'imSystemSettings', + 'imMessageEncryptionIndex', + 'imMessageEncryptionInitialize', + 'imMessageEncryptionRotate', + 'imMessageEncryptionSwitch' +); diff --git a/server/utility/encrypt/aes.go b/server/utility/encrypt/aes.go index a38f4c8..01e7cec 100644 --- a/server/utility/encrypt/aes.go +++ b/server/utility/encrypt/aes.go @@ -6,10 +6,56 @@ package encrypt import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" "encoding/base64" + "fmt" "github.com/forgoer/openssl" + "io" ) +const AESGCMKeySize = 32 + +// AESGCMEncrypt encrypts plaintext with AES-256-GCM and a fresh random nonce. +func AESGCMEncrypt(plaintext, key, additionalData []byte) (ciphertext, nonce []byte, err error) { + if len(key) != AESGCMKeySize { + return nil, nil, fmt.Errorf("AES-256-GCM key must be %d bytes", AESGCMKeySize) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, nil, err + } + nonce = make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return nil, nil, err + } + return gcm.Seal(nil, nonce, plaintext, additionalData), nonce, nil +} + +// AESGCMDecrypt authenticates and decrypts ciphertext encrypted by AESGCMEncrypt. +func AESGCMDecrypt(ciphertext, nonce, key, additionalData []byte) ([]byte, error) { + if len(key) != AESGCMKeySize { + return nil, fmt.Errorf("AES-256-GCM key must be %d bytes", AESGCMKeySize) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + if len(nonce) != gcm.NonceSize() { + return nil, fmt.Errorf("AES-GCM nonce must be %d bytes", gcm.NonceSize()) + } + return gcm.Open(nil, nonce, ciphertext, additionalData) +} + // AesECBEncrypt 加密 func AesECBEncrypt(src, key []byte) (dst []byte, err error) { return openssl.AesECBEncrypt(src, key, openssl.PKCS7_PADDING) diff --git a/server/utility/encrypt/aes_gcm_test.go b/server/utility/encrypt/aes_gcm_test.go new file mode 100644 index 0000000..320688c --- /dev/null +++ b/server/utility/encrypt/aes_gcm_test.go @@ -0,0 +1,34 @@ +package encrypt + +import ( + "bytes" + "testing" +) + +func TestAESGCMRoundTripAndRandomNonce(t *testing.T) { + key := bytes.Repeat([]byte{7}, AESGCMKeySize) + plain := []byte("message encryption key") + aad := []byte("tenant:1:version:1") + first, firstNonce, err := AESGCMEncrypt(plain, key, aad) + if err != nil { + t.Fatal(err) + } + second, secondNonce, err := AESGCMEncrypt(plain, key, aad) + if err != nil { + t.Fatal(err) + } + if bytes.Equal(firstNonce, secondNonce) || bytes.Equal(first, second) { + t.Fatal("repeated encryption must use a different nonce and ciphertext") + } + decoded, err := AESGCMDecrypt(first, firstNonce, key, aad) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(decoded, plain) { + t.Fatalf("unexpected plaintext: %q", decoded) + } + first[0] ^= 1 + if _, err = AESGCMDecrypt(first, firstNonce, key, aad); err == nil { + t.Fatal("tampered ciphertext must fail authentication") + } +} diff --git a/web/src/api/imMessageEncryption/index.ts b/web/src/api/imMessageEncryption/index.ts new file mode 100644 index 0000000..802940a --- /dev/null +++ b/web/src/api/imMessageEncryption/index.ts @@ -0,0 +1,17 @@ +import { http } from '@/utils/http/axios'; + +export function List(params) { + return http.request({ url: '/imMessageEncryption/list', method: 'GET', params }); +} + +export function Initialize(params) { + return http.request({ url: '/imMessageEncryption/initialize', method: 'POST', params }); +} + +export function Rotate(params) { + return http.request({ url: '/imMessageEncryption/rotate', method: 'POST', params }); +} + +export function Switch(params) { + return http.request({ url: '/imMessageEncryption/switch', method: 'POST', params }); +} diff --git a/web/src/views/imMessageEncryption/index.vue b/web/src/views/imMessageEncryption/index.vue new file mode 100644 index 0000000..eb47a17 --- /dev/null +++ b/web/src/views/imMessageEncryption/index.vue @@ -0,0 +1,137 @@ + + + diff --git a/web/src/views/imMessageEncryption/model.ts b/web/src/views/imMessageEncryption/model.ts new file mode 100644 index 0000000..ddde41e --- /dev/null +++ b/web/src/views/imMessageEncryption/model.ts @@ -0,0 +1,87 @@ +import { h, ref } from 'vue'; +import { NTag } from 'naive-ui'; +import { FormSchema } from '@/components/Form'; + +export class State { + public tenantId = 0; + public tenantCode = ''; + public tenantName = ''; + public messageEncryptionEnabled = 0; + public activeVersion = 0; + public pendingVersion = 0; + public keyCount = 0; + public lastRotatedAt = ''; +} + +export const schemas = ref([ + { + field: 'keyword', + component: 'NInput', + label: '商户', + componentProps: { + clearable: true, + placeholder: '商户编码/名称', + }, + }, + { + field: 'enabled', + component: 'NSelect', + label: '加密状态', + componentProps: { + clearable: true, + options: [ + { label: '已启用', value: 1 }, + { label: '未启用', value: 0 }, + ], + placeholder: '全部', + }, + }, +]); + +export const columns = [ + { title: '商户编码', key: 'tenantCode', align: 'left', width: 150 }, + { title: '商户名称', key: 'tenantName', align: 'left', width: 180 }, + { + title: '消息加密', + key: 'messageEncryptionEnabled', + align: 'left', + width: 110, + render(row: State) { + return h( + NTag, + { type: row.messageEncryptionEnabled ? 'success' : 'default', bordered: false }, + { default: () => (row.messageEncryptionEnabled ? '已启用' : '未启用') } + ); + }, + }, + { + title: '当前版本', + key: 'activeVersion', + align: 'left', + width: 110, + render(row: State) { + return row.activeVersion > 0 ? `v${row.activeVersion}` : '--'; + }, + }, + { + title: '待激活版本', + key: 'pendingVersion', + align: 'left', + width: 120, + render(row: State) { + return row.pendingVersion > 0 + ? h(NTag, { type: 'warning', bordered: false }, { default: () => `v${row.pendingVersion}` }) + : '--'; + }, + }, + { title: '版本数', key: 'keyCount', align: 'left', width: 90 }, + { + title: '最后轮换时间', + key: 'lastRotatedAt', + align: 'left', + width: 180, + render(row: State) { + return row.lastRotatedAt || '--'; + }, + }, +]; diff --git a/web/src/views/imTenant/edit.vue b/web/src/views/imTenant/edit.vue index 280502e..5c54a19 100644 --- a/web/src/views/imTenant/edit.vue +++ b/web/src/views/imTenant/edit.vue @@ -100,6 +100,15 @@ /> + + + + + (row.messageEncryptionEnabled === 1 ? '已启用' : '未启用') } + ); + }, + }, { title: '实名审核', key: 'realnameReviewPolicy',