引言
随着洞窝业务规模不断扩大,系统中存储的用户隐私数据也越来越多,越来越丰富,考虑到用户隐私数据的安全性以及公司风险合规性,现需要对底层数据库及前端页面做数据安全管控。安全控制一直是治理的重要环节,数据加密属于安全控制的范畴。无论对互联网公司还是传统行业来说,数据安全一直是极为重视和敏感的话题。隐私数据泄露不但要承受工信部的巨额罚款,更会给公司的信誉带来严重的负面影响。
敏感数据挑战
涉及客户安全数据或者一些商业性敏感数据,如身份证号、手机号、卡号、客户号等个人信息按照相关部门规定,都需要进行数据加密。
洞窝现有用户量几千万,涉及订单数量近千万,以及其他优惠信息、商户信息、卖场店铺信息等涉及数据库表格60+,涉及数据库表字段200+,对于庞大的历史数据处理以及宽广的涉及面来讲如何对涉及到每一个字段进行脱敏又不影响现有功能成为数据库脱敏的核心难点。如何在脱敏数据与明文数据共存的情况下实现系统的平滑升级是数据库脱敏的又一挑战。
数据脱敏的复杂性
系统难点
(1)涉及存量数据量大
用户表涉及几千万,用户相关优惠券信息数据量近亿,订单表涉及数据量近千万等等,庞大的历史数据处理需要清洗,数据清洗过程中如何保证业务正常使用。
(2)平滑切换
涉及数据量大导致在对历史数据处理过程中如何确保再业务无感知情况下平滑切换是本次项目的重中之重。
(3)数据查询
之前的敏感字段模糊查询在数据脱敏后对数据进行处理后不在支持模糊查询。
(4)索引处理
敏感字段有涉及索引的情况,对涉及到的唯一索引如何进行处理,普通索引如何处理。
解决方案
(1)阔字段
为保证历史数据处理过程中系统能够正常运行并对外提供服务,我们不对源字段进行脱敏处理,在源数据表基础上新增一个脱敏字段,讲源字段数据加密后保存到新扩增的字段上。
(2)程序第一次发布。原字段、加密字段均存入数据
在程序上线后保证新增的数据在源字段保存明文数据,在新扩增的加密字段上保存加密字段,这样程序首次上线后即可保证新增进来的数据加密字段有值。
(3)清洗历史数据
然后对历史数据进行清洗,讲原明文字段加密后存放在新扩的加密字段中,待数据清洗完成后保证整张表中加密字段都保存了数据。
(4)加索引
对加密字段加与源字段相同的索引。之所以在阔字段时未加索引,将加索引放置最后一步是因为有些原字段是唯一索引,在清洗数据前历史数据加密字段为null,曾加唯一索引失败。
(5)程序二次发布、查询加密字段
将程序中查询语句走加密字段进行解密,新数据仍然即保存源字段又保存加密字段以供验证及回滚。
(6)千里行军,最后一步,只保存加密字段、清空源字段
待测试通过程序运行一段时间无误后,新增数据只保存加密字段数据,源字段不在保存数据,并且将所有数据原字段清空,项目执行完毕。
技术选型
加密算法选型
1.RSA非对称加密算法
非对称加密算法是指加密和解密采用不同的密钥(公钥和私钥),因此非对称加密也叫公钥加密,是可逆的(即可解密)。公钥密码体制根据其所依据的难题一般分为三类:大素数分解问题类、离散对数问题类、椭圆曲线类。
RSA加密算法是基于一个十分简单的数论事实:将两个大素数相乘十分容易,但是想要对其乘积进行因式分解极其困难,因此可以将乘积公开作为加密密钥。虽然RSA的安全性一直未能得到理论上的证明,但它经历了各种攻击至今未被完全攻破。
**优点:**加密和解密的密钥不一致,公钥是可以公开的,只需保证私钥不被泄露即可,这样就密钥的传递变的简单很多,从而降低了被破解的几率。
**缺点:**加密速度慢。
结论:由于rsa加密算法每次加密结果都不相同,所以不能采用此算法作为数据加密,不利于数据查询。
2.MD5加密算法
MD5全称是Message-Digest Algorithm 5(信息摘要算法5),单向的算法不可逆(被MD5加密的数据不能被解密)。MD5加密后的数据长度要比加密数据小的多,且长度固定,且加密后的串是唯一的。
结论:由于md5加密算法不可逆,无法对密文进行解密,不利于查询结果的展示,故舍弃。
3.AES加密算法
对称加密算法是指加密和解密采用相同的密钥,是可逆的(即可解密)。
AES加密算法是密码学中的高级加密标准,采用的是对称分组密码体制,密钥长度的最少支持为128。AES加密算法是美国联邦政府采用的区块加密标准,这个标准用来替代原先的DES,已经被多方分析且广为全世界使用。
**优点:**加密速度快,另一优点是mysql现有函数支持aes加密算法,清洗历史数据不用再建定时任务直接使用存储过程即可清洗历史数据。
**缺点:**密钥的传递和保存是一个问题,参与加密和解密的双方使用的密钥是一样的,这样密钥就很容易泄露。
结论:由于aes现有的先天性优势,能加密解密,有利于数据存储,历史数据处理方案也更简便,故选择aes算法。
加密方案选型
shardingsphere解决方案
Apache ShardingSphere 通过对用户输入的 SQL 进行解析,并依据用户提供的加密规则对 SQL 进行改写,从而实现对原文数据进行加密,并将原文数据(可选)及密文数据同时存储到底层数据库。
在用户查询数据时,它仅从数据库中取出密文数据,并对其解密,最终将解密后的原始数据返回给用户。
Mybatis解决方案
利用Mybatis拦截器+反射机制,设计加解密注解,可以对特定字段入库出库时,实现自动加解密。
自定义注解对需要进行加解密处理的方法加注解,标明需要加密的字段。当程序执行到mybatis层时进行拦截,如果程序未加注解则继续执行,如果有注解将对数据进行加解密处理。
Typehadler解决方案
JDBC类型与Java类型并不是完全一一对应的。所以在PreparedStatement绑定参数的时候需要把Java类型转为JDBC类型。JDBC类型的枚举值在JdbcType枚举值中存储。
在我们利用mybatis作为持久层框架存储数据时,从mybatis接收参数到MySQL存储数据,都会用到typeHandler类型处理器。这也就是从JavaType->JdbcType的转化过程。由于mybatis初始时已经内置大部分基础类型转化的TypeHandler,已经足够我们平常的简单应用开发了,所以大多数情况下并不需要我们自己去定义类型转换器。但是,当遇到一些特殊情况时,为了开发的方便性,我们才回去自定义一些类型转换器
方案对比及结论
(1)typehandler
优点:原理简单,实现方便。
缺点:上线流程复杂,针对每一个文件、每一条sql都需要修改。
(2)mybatis拦截器
优点:方案原理、实现简单。
缺点:上线流程复杂。
(3)sharding-jdbc
优点:方案原理简单,修改简单,上线流程简单。
缺点:对sql支持度不友好,对子查询等sql不能完全支持。
(4)结论
Sharding-jdbc改动小,上线流程相对简单。不需要改两份线上代码,故确定使用sharding-jdbc方案。
实施过程
1.整体流程
2.准备ddl
将涉及到的所有表格、所有字段增加密文列用以存储密文。所有涉及到的表格字段如下图。
3.代码部分修改
由于涉及表格、字段非常多,若用现有文档的配置方式nacos配置文件将会非常大,后续配置文件维护非常困难。所以我们关闭了shardingsphere的自动装配功能,自己实现shardingsphere的启动类来加载涉及到的表格字段。将涉及到的表格字段维护到一个静态map中,在程序启动时将需要脱敏的表格字段加载到数据源当中便于维护。
静态map维护加密字段映射信息
java
public static Map<String, List<EncryptDbTableMapping>> ENCRYPT_TABLE_COLUMN_MAP = new HashMap<>();
public static Map<String,List<String>> tradeTableColumn = new HashMap<>();
static {
List<String> easy_order_shops = Lists.newArrayList();
easy_order_shops.add("buyer_phone");
easy_order_shops.add("shop_guide_phone");
tradeTableColumn.put("easy_order_shops",easy_order_shops);
}
static {
ENCRYPT_TABLE_COLUMN_MAP.put("easyhome-trade", generateDbTableMapping("easyhome-trade",tradeTableColumn));
}
public static List<EncryptDbTableMapping> generateDbTableMapping(String dbName,Map<String,List<String>> tableColumn){
List<EncryptDbTableMapping> appuserDbTbaleMapping = Lists.newArrayList();
for(Map.Entry<String,List<String>> entry:tableColumn.entrySet()) {
appuserDbTbaleMapping.add(generateTableMapping(dbName, entry.getKey(), entry.getValue()));
}
return appuserDbTbaleMapping;
}
public static EncryptColumnMapping generateColumnMapping(String logicColumnName,String plainColumnName,String cipherColumnName){
EncryptColumnMapping build = EncryptColumnMapping
.builder()
.cipherColumnName(cipherColumnName)
.logicColumnName(logicColumnName)
.plainColumnName(plainColumnName).build();
return build;
}
public static EncryptDbTableMapping generateTableMapping(String dbName,String tableName,List<String> columnsParam){
List<EncryptColumnMapping> columnMappingList = Lists.newArrayList();
for(String column:columnsParam){
EncryptColumnMapping encryptColumnMapping = generateColumnMapping(column, column, column+"_encrypt");
columnMappingList.add(encryptColumnMapping);
}
EncryptDbTableMapping dbTableMapping = EncryptDbTableMapping
.builder()
.dbName(dbName)
.tableName(tableName)
.columnMappingList(columnMappingList)
.build();
return dbTableMapping;
}
数据源初始化时将映射信息加载到数据源
java
@Configuration
@AutoConfigureBefore({ ShardingSphereAutoConfiguration.class})
public class DataSourceConfiguration {
@Value("${spring.shardingsphere.props.query.with.cipher.column:false}")
private Boolean queryWithCipher;
@Value(("${spring.shardingsphere.encrypt.encryptors.encryptor.type:db_encryptor}"))
private String encryptorType
@Value(("${spring.shardingsphere.encrypt.encryptors.encryptor.props.aes.key.value:fly13579@#}"))
private String encryptionKey ;
@Value("${spring.shardingsphere.encrypt.sql.show:true}")
private String sqlShow;
@Bean
@Primary
@ConfigurationProperties(prefix = "spring.datasource.druid.readwriteds")
public DataSource apptradeReadWriteDataSource(){
DruidDataSource build = DruidDataSourceBuilder.create().build();
return build;
}
@Bean
public DataSource apptradeReadWriteEncryptDataSource(@Qualifier("apptradeReadWriteDataSource")DataSource dataSource) throws SQLException {
Properties props = new Properties();
props.setProperty("aes.key.value", encryptionKey);
props.setProperty("query.with.cipher.column",String.valueOf(queryWithCipher));
props.setProperty("sql-show",sqlShow);
EncryptRuleConfiguration encryptConfig = DbEncryptDataSourceConfig.createEncryptConfig(props, encryptorType, "easyhome-trade");
Collection<RuleConfiguration> configs = new ArrayList<>();
configs.add(encryptConfig);
return ShardingSphereDataSourceFactory.createDataSource("easyhome-trade",dataSource,configs , props);
}
@Bean(name = "dynamicSaasDataSource", initMethod = "init")
public DataSource myRoutingDataSource(@Qualifier("apptradeReadWriteEncryptDataSource") DataSource apptradeReadWriteEncryptDataSource,
@Qualifier("apptradeReadWriteDataSource") DataSource apptradeReadWriteDataSource, ) {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put(DataSourceConstant.SHARDING_MASTER, apptradeReadWriteEncryptDataSource);
targetDataSources.put(DataSourceConstant.MASTER, apptradeReadWriteDataSource);
myRoutingDataSource.setDefaultTargetDataSource(apptradeReadWriteDataSource);
myRoutingDataSource.setTargetDataSources(targetDataSources);
return myRoutingDataSource;
}
创建映射关系、构建加密规则
java
public class DbEncryptDataSourceConfig {
public static EncryptRuleConfiguration createEncryptConfig(Properties props, String encryptorType, String dbName ){
AlgorithmConfiguration encryptorConfig = new AlgorithmConfiguration(encryptorType, props);
Map<String,AlgorithmConfiguration> algorithmConfigurationMap = new HashMap<>();
List<EncryptDbTableMapping> encryptDbTableMappings = EncryptTableColumn.ENCRYPT_TABLE_COLUMN_MAP.get(dbName);
List<EncryptTableRuleConfiguration> tableConfigRuleList = new ArrayList<>();
EncryptRuleConfiguration encryptRuleConfig = new EncryptRuleConfiguration(tableConfigRuleList,algorithmConfigurationMap);
Boolean queryWithCipherColumn = Boolean.valueOf(props.getProperty("query.with.cipher.column"));
String sql_show = props.getProperty("sql-show");
for(EncryptDbTableMapping mapping :encryptDbTableMappings) {
List<EncryptColumnRuleConfiguration> configurationMap = new ArrayList<>();
for(EncryptColumnMapping column:mapping.getColumnMappingList()) {
EncryptColumnRuleConfiguration columnConfig = new EncryptColumnRuleConfiguration(column.getPlainColumnName(), column.getCipherColumnName(), "", StringUtils.EMPTY,"aes",queryWithCipherColumn);
configurationMap.add(columnConfig);
};
EncryptTableRuleConfiguration tableConfig = new EncryptTableRuleConfiguration(mapping.getTableName(),configurationMap,queryWithCipherColumn);
encryptRuleConfig.getTables().add(tableConfig);
}
encryptRuleConfig.getEncryptors().put("aes", encryptorConfig);
props.setProperty("sql-show",sql_show);
return encryptRuleConfig;
}
}
4.程序第一次发布
上述代码修改完成后,所有sql语句都不需要修改,shardingsphere自动改写sql,在数据存储过程中自动将明文字段、密文字段均存储到数据库中。
程序发布完成后注意观察数据库中对应表格数据,新插入的数据及更新过的数据是否明文、密文字段都存储了数据。
如果跟预期一致,明文、密文字段都存储了数据,那么万里长征第一步大功告成。
5.清洗历史数据
Shardingshphere只能保证新入库的数据已经明文密文字段都进行了存储,对于历史数据还是需要自行清洗。清洗历史数据可以自己写程序通过程序进行清洗,由于我们设计表格字段太多、太繁琐,不适合使用程序清洗,单独写程序对每一个字段进行清洗既耗时,又容易出错。我们选择了使用数据库存储过程对历史数据进行清洗。
准备清洗数据sql:
sql
CREATE PROCEDURE easy_order_shops_update_sql()
BEGIN
DECLARE begin_id INT(12);
DECLARE end_id INT(12);
DECLARE t_step INT(12);
DECLARE v_bg INT(12);
DECLARE v_end INT(12);
DECLARE sc_key varchar(200);
SELECT id INTO begin_id from easy_order_shops ORDER BY id asc LIMIT 1;
SELECT id INTO end_id from easy_order_shops ORDER BY id DESC LIMIT 1;
set sc_key = '手机号密钥';
SET t_step = 10000;
SET v_bg = begin_id;
SET v_end = v_bg + t_step;
WHILE v_bg <= end_id DO
UPDATE easy_order_shops
SET shop_guide_phone_encrypt = to_base64 (AES_ENCRYPT( shop_guide_phone, sc_key)) ,
buyer_phone_encrypt = to_base64 (AES_ENCRYPT( buyer_phone, sc_key))
WHERE
id BETWEEN v_bg and v_end;
COMMIT;
SET v_bg = v_end + 1;
SET v_end = v_bg + t_step;
end WHILE;
select '处理完成!' + end_id;
end;
CALL easy_order_shops_update_sql();
drop PROCEDURE easy_order_shops_update_sql;
之所以采用存储过程对历史数据清洗除了涉及到的表格字段太多、太杂的原因外,还有一个很重要的原因是如果只用sql对数据进行清洗,表格数据量大的情况下会锁表,影响正常的线上交易。通过上述存储过程完成之后即可确保线上所有数据明文、密文字段均已存储数据。
至此为止数据库脱敏大部分工作已经就绪。下面即是看脱敏效果的时候。
6.增加索引
为保证查询效率,不至于出现慢sql查询,须对加密字段增加与原明文字段相同索引类型的索引。
之所以选择在清洗完数据后对密文字段增加索引而不是在第一步扩展字段时增加索引原因是因为有写字段索引是唯一索引,新扩展的密文字段历史数据为null,增加唯一索引失败。故选择在清洗完历史数据后增加索引。
另外需要注意的是对于数据量较大的表格增加索引会导致数据库锁表影响线上系统运行,故增加索引时应选择在业务低谷时进行,我们增加索引选择在凌晨2点进行。
增加完索引后观察系统日志及数据查询情况,判断索引是否有效,加密是否与预期一致。此步非常关键,因为下一步将会对查询字段进行切换,切换后数据查询将走密文字段,如果查询没有走到索引或索引失效抑或新入库的数据及历史数据加密错误下一步都将对生产产生极大的影响。
7.切换查询字段
切换查询字段其实非常简单,既不需要修改代码,也不需要修改数据库,只需要将nacos配置中的false修改成true,重启服务后系统查询语句将自动切换成按照密文字段进行查询,shardingsphere中有两个配置项,一个配置项是是否使用密文字段进行查询,另外一个配置项是打印sql语句,打开按密文查询配置项时建议将打印sql配置项同时打开,这样可以监控sql语句是否按照密纹列进行查询,具体配置如下:
spring:
xml
spring:
shardingsphere:
encrypt:
sql:
show: true # 是否打印sql语句
props:
query:
with:
cipher:
column: true # 查询是否使用密文列
配置修改完成后,重启应用即可监控sql。系统重启后一定要注意监控日志及系统运行情况看,因为此步由为关键,万里长征只为此,此步如果成功后面将水到渠成。
8.停止存储明文字段
因为系统上线后需要一段时间观察,监控是否正常、有无客诉等情况,程序运行一段时间(我们试运行1个月)正常无误后停止明文字段存储。
停止明文存储其实也非常简单,不需要修改任何sql语句,只需要在数据源实例化时将明文字段名去掉或修改成空即可,具体代码如下:
此处设置成空后,重新部署应用,新入库的数据明文字段不在存储,程序发布后监控数据库查询明文字段与密文字段的存储情况即可。
9.清空明文字段历史数据
此处我们仍旧采用存储过程对明文字段进行清空,采用存储过程的原因与清洗密文字段原因相同,数据量大的情况下直接使用sql进行清洗会导致数据库锁表、主从延迟等一系列生产问题,故采用存储过程对历史数据分批清洗处理。具体存储过程代码与上述清洗数据相似,此处不再赘述。
至此,一个涉及60多张表,200多个字段,几亿条数据的数据脱敏完整过程已经完成。
测试与优化
1.测试方案
(1)项目自测(sql测试)
项目自测阶段,我们开发完成后对项目中涉及到的sql语句采用test方式进行sql全量测试,保证全部sql在切换数据源后能够正常运行。
(2)自动化测试(接口测试)
由于项目已介入了自动化测试接口,我们可以将项目部署到测试环境后启动自动化接口测试,对全量接口、不同参数情况下进行接口自动化测试,校验数据加密的影响及效果。
(3)测试回归
qa测试,qa人员对项目涉及到的主流程进行全流程测试,保证程序上线后主要流程能够正常运行。
2.性能评估
(1)sql查询效率
因为在程序上线数据清洗完成后,对所有密文字段增加与原明文字段相同类型的索引,所以一sql执行效率对程序原则上没有影响,经项目实测后确定对项目运行无影响。
(2)接口相应时长
因为程序在运行过程时,在进行数据库操作前、操作后会对密文字段进行脱敏处理后再进行数据库操作,会对接口相应时长有一定影响。但是在小数据量的情况下影响可控。对于大批量的定时器应作特殊处理。譬如,定时器改成多线程执行或改成分布式任务处理。
结论
项目回顾
项目2022年11月初开始进入开发,共处理表格67个,字段192个,共处理历史数据近1亿条,历史数据最大表格数据量近亿,2022年12月6日上线,距今已上线一年,现项目运行正常。
遇到问题及处理方案
项目开发阶段曾遇到过一些问题,问题及处理方案如下:
(1)对复杂sql及子查询支持不友好,很多sql无法解析。处理方案是采用路由策略只有当sql中涉及到加密字段时才采用shardingsqphere加密数据源,不涉及加密字段的仍采用druid数据源。
(2)shardingsphere不同版本差异很大,甚至不同版本的使用方式都有差异。建议使用最新版本,最新版本对一些历史问题进行过处理,特别时sql支持问题。
(3)shardingsphere加密数据源原生代码不支持加密字段为空的情况,需要自己实现原代码进行处理。
附录
-
京东实战:数据脱敏如何避免系统重构或修改? dbaplus.cn/news-159-27...
-
shardingsphere官方文档:数据源脱敏原理 shardingsphere.apache.org/document/5....
作者:洞窝-杨廷双