今年 1 月,国家计算机病毒应急处理中心通报了一批违规收集个人信息的 App,其中一个相亲平台被点名的三项问题里,有一项是"未采取相应的加密、去标识化等安全技术措施"。这条通报在婚恋平台圈子里被反复引用——五部门婚介整治方案里也写了类似要求。而对任何一个婚恋平台的开发团队来说,这句话落到数据库上,是一个很具体的两难:加密了,就没法查询了。
红娘要靠手机号联系会员,匹配模块要按年龄、城市、婚姻状况筛人,风控要按身份证号查重。这些字段全是敏感信息,加密存储是合规底线;可一旦密文进库,WHERE phone = ?就成了摆设。我们在这件事上折腾过一轮,把方案和踩过的坑记录下来。
婚恋平台的敏感字段,为什么这么难存
先把"敏感"拆开看。婚恋平台要存的敏感字段大致分两类,处理方式完全不同。
第一类是强标识字段:手机号、身份证号。它们的特点是需要精确匹配——红娘录入线索时输入手机号,系统要能查到对应会员;注册时要查身份证号有没有被拉黑过。
第二类是画像字段:收入范围、房车情况、身高学历。它们的特点是范围查询和筛选,且大多是用户自己填的、半公开的展示性内容,在婚恋平台的语境里,真正致命的是身份证和人脸这类强敏感信息。
很多团队的第一反应是把整库加密(TDE)或磁盘加密,然后宣布"我们加密了"。对婚恋平台来说,这确实能防拖库,但防不了两件事:有业务查询权限的内部人员依然能看到明文;日志、慢查询、导出文件里的明文照样裸奔。新规里点名的"员工随意查阅、复制、传播征婚者信息",指的就是这一层。
所以真正要做的是字段级加密——让数据库里落盘的就是密文,解密只发生在应用层,且按角色授权。但接下来就是那个绕不开的问题:怎么查。
一张表的演进:从明文到密文加盲索引
我们的会员表敏感字段经历过三个版本。
第一版,明文直存,就是大多数小机构 Excel 思维的直接迁移:
CREATETABLEmember(idBIGINTPRIMARYKEY,nameVARCHAR(64),phoneVARCHAR(20),-- 明文 id_card VARCHAR(18), -- 明文 created_at DATETIME);第二版,简单加密,把phone换成phone_cipher VARBINARY(256),AES-GCM 加密后直接写。写进去没问题,问题出在查询:密文每次加密都带随机 IV,同一个手机号两次加密结果不同,等值查询直接失效。婚恋平台的手机号搜索一坏,业务方反馈的第一句话就是"搜索功能坏了"。
第三版,才定下现在的结构:密文列 + 盲索引列分列存。
CREATETABLEmember_sensitive(member_idBIGINTPRIMARYKEY,phone_cipherVARBINARY(256)NOTNULL,-- AES-GCM 密文 phone_bidx BINARY(32) NOT NULL, -- HMAC-SHA256 盲索引 id_card_cipher VARBINARY(256) NOT NULL, id_card_bidx BINARY(32) NOT NULL, key_version SMALLINT NOT NULL DEFAULT 1, updated_at DATETIME, INDEX idx_phone_bidx (phone_bidx), INDEX idx_idcard_bidx (id_card_bidx));写入和查询的逻辑在应用层:
importhmac,hashlib,osfrom cryptography.hazmat.primitives.ciphers.aeadimportAESGCMdef encrypt_field(plaintext:bytes,enc_key:bytes,mac_key:bytes):iv=os.urandom(12)cipher=AESGCM(enc_key).encrypt(iv,plaintext,None)bidx=hmac.new(mac_key,plaintext,hashlib.sha256).digest()returniv+cipher,bidx# 密文与盲索引同源明文,天然一致def query_by_phone(phone: str, mac_key: bytes): bidx = hmac.new(mac_key, phone.encode(), hashlib.sha256).digest() # SELECT member_id, phone_cipher FROM member_sensitive WHERE phone_bidx = %s ...这里的关键设计有三个,都是踩坑换来的。
第一,加密密钥和盲索引的 MAC 密钥必须分开。早期我们图省事用同一个 key,后来做密钥轮换评审时才意识到:盲索引是拿去建索引、进日志、做跨表关联的,暴露面比密文大得多,两把钥匙的权限边界应该隔开。
第二,盲索引要先做归一化再算 HMAC。手机号有 +86 前缀、空格、横线各种写法,直接对原始输入算 HMAC,同一个号码会算出多个盲索引。我们在归一化上吃的亏比在加密本身上多得多——之前写线索撞单那篇时提过手机号归一化,两处用的是同一套规则。
第三,盲索引不是匿名的,别当它是匿名化。婚恋平台的手机号总共就那么多,拿着彩虹表对盲索引做暴力枚举是可行的。所以盲索引列的访问权限要跟密文列同等对待,导出和审计一起管。这一点我们在安全评审时被外部顾问点醒过,算是纠正了一个想当然。
展示层脱敏:按角色决定能看到什么
存储加密解决的是婚恋平台被拖库、或有人绕过应用直连数据库的问题,但红娘总得看到能用的信息。我们的原则是:解密发生在应用层,脱敏发生在序列化前,按角色给不同视图。
| 角色 | 手机号 | 身份证号 | 收入 |
|---|---|---|---|
| 红娘(服务中) | 138****5678,可一键完整外呼 | 全程不可见 | 区间 |
| 会员本人 | 完整 | 尾号 4 位 | 完整 |
| 运营/客服 | 不可见,需审批单号解封 | 不可见 | 不可见 |
| 数据分析 | 永远只见密文/盲索引 | 同左 | 区间 |
实现上是一个很薄的序列化拦截器:
MASK_RULES={"phone":lambdas:s[:3]+"****"+s[-4:]}defserialize_member(member,role):forfield,ruleinMASK_RULES.items():raw=decrypt(member[field])# 应用层解密,密钥不出 KMS 边界 member[field] = rule(raw) if not can_see(role, field) else raw return member两个容易忽略的点。一是完整信息的使用必须留痕:红娘点"查看完整手机号"这个动作,我们会记一条审计事件(谁、哪个会员、哪个时间、哪个会话),跟之前写导出审批那篇是同一套审计链路,只是粒度到单次查看。二是脱敏要在出口统一做,不要散在各业务接口里自己写格式——散着写的结果一定是某天某个新接口忘了,明文就漏出去了。
密钥轮换:key_version 留后路
字段级加密最容易被推迟的问题是一旦要换密钥怎么办。我们的做法是密文自带key_version,解密时按版本取密钥,写入永远用最新版。轮换时跑一个后台任务按 id 分批重加密,每批 500 条,失败自动重试三次后人工介入。存量 30 万级数据全量轮换跑了一个周末(示例数据,视机器规格而定),期间业务无感。
这个设计在表结构里就预留了,成本几乎为零;事后补的话,等于把全表密文重新解密再加密一遍,还要处理"轮换中途宕机"的一致性问题。建议刚开始做字段级加密的婚恋平台团队,第一天就把 key_version 加上。
复盘:合规要求和产品体验不冲突,冲突的是没想清楚
回头看,这条链路拆开是三件事:存储层密文落盘(AES-GCM)+ 检索层盲索引(HMAC-SHA256)+ 展示层按角色脱敏。三层各管一段,对婚恋平台来说,任何一层单独上都会被业务方骂——只加密不解决检索,搜索坏了;只脱敏不加密,拖库照丢;只做前两者不控出口,导出和日志照样漏。
监管通报里那句"未采取加密、去标识化等安全技术措施",翻译成工程语言就是这三层。它不便宜,但也没有想象中贵,最难的部分其实是要说服业务方接受"红娘默认只看到 138****5678"。这一步谈下来,剩下的都是纯技术活。
文中方案来自我们在婚恋行业 SaaS(云中红线)的一线实践,欢迎交流。