前言
数据是信息系统的核心资产,数据安全现在越来越受到企业的重视,虽然在数据库外围能够很大程度上防止数据泄露,但核心数据的安全容不得半点差错,数据库加密因此应运而生,它作为数据安全的最后一道防线,确保即使数据库被攻陷,存储在数据库中的数据仍然可以得到有效保护。数据库加密主要用来解决以下两个方面的问题:1.数据库被拖库后,避免因为明文存储导致数据泄露;2.防范内部高权限用户,窃取数据导致数据泄露。数据加密通常也被叫做数据脱敏,但大多数系统的数据脱敏仅限于应用层脱敏,数据库层面存储的依旧是明文,这始终都是一个安全隐患。
年前的时候,公司客户提出了强制性要求,需要对存储在数据库的人员信息,比如身份证号,姓名,住址等敏感信息进行数据脱敏,之前其实一直有人在研究,但始终没有找到好的办法,所以一度处于搁置状态,现在客户催的紧,火烧眉毛,于是转为由我负责推动这件事。
最后,经过多方对比,研究,讨论,我选择对 mycat 的源码进行改造,终于实现了数据库透明加解密的需求,可以进行列级别的加密存储和解密,自研加密算法,可以支持加密字段的 like 查询,也可以自定义加解密算法,对应用无感知,无需修改业务代码,目前已在公司投入使用近一个月,一直处于稳定运行的状态,下面详细介绍一下。(源码等资料可于文末获取)
方案选择
我查阅了一些资料,数据库加密主要有以下几种主流方案:
1.应用系统加密
此种加密方式是最容易想到的,即在编码层面进行数据加密,对需要加密的字段在存储或更新的时候加密,查询的时候解密,但是改造成本实在过于高昂,而且对业务侵入性过强,日后如果有新的字段加密需求又是一波新的改造。
2.前置代理网关加密
前置代理网关本质上是数据库代理中间件,通过对 sql 拦截解析,对需要加密的字段加密,对响应结果解密,返回给客户端,对应用系统基本透明,只需要修改数据库连接配置即可,但由于使用了中间层代理,对代理的稳定性要求较高,并且不可避免的会造成一定程度的性能损耗。
3.数据库后置加密
某些数据库厂商在数据库引擎层增加了一些扩展接口和扩展机制,通过这些,数据库系统用户可以通过外部接口调用的方式对数据进行加解密处理,对应用系统透明,但支持的数据库类型有限,不支持 mysql。
经过公司内部讨论,决定采用第二种方案,当然,现在市面上有专门提供数据库加密服务的厂商,只要花钱就可以搞定,但不到万不得已,不会花钱的,懂的都懂,不再多说。
自己从头写一个数据库代理并不现实,于是决定站在巨人的肩膀上实现,现在 Java 领域比较出名的数据库中间件主要是 mycat 和 shardingSphere,虽然二者主打的功能是分库分表,但同样也是优秀的数据库代理中间件,其实依本人所见,分库分表能不用则不用,威力可能很大,但最后数据库也给整残废了,维护起来成本过高,而且这种配置方式的分库分表其实算伪分布式数据库的实现,日后如果扩充节点,改变分片规则等会异常麻烦,还是多从业务角度考虑较好一些。
最后,通过拜读二者源码,发现 mycat 修改起来相对容易一些,shardingSphere 虽然有数据脱敏的功能,但经过测试,发现 like 查询并不支持,复杂的子查询 sql 解析不了,仍需完善,并且源码体积异常庞大,修改难度过高,最后选择修改 mycat 源码,增加加解密模块,实现数据库加解密功能。
改造 mycat 实现加解密
最后经过各种测试,决定在 1.6.7.6-release 版本的基础上进行改造,改造的部分主要包括:拦截解析 sql 加密,返回结果解密,此外,为了实现可配置的加解密功能,我添加了 encrypt.xml 配置文件,在 server.xml 中增加了加解密相关的属性,在下一小节详细介绍,在这里我们只关注核心部分的代码,如果大家有兴趣,可以在文末获取源码研究。
1.拦截解析 sql 加密
我们需要在 mycat 对 sql 路由之前改写 sql,实现加密字段的加密,代码位于 ServerConnection 的 routeEndExecuteSQL 方法中,我们在这里添加相关的加密逻辑即可,核心代码如下:
public void routeEndExecuteSQL(String sql, final int type, final SchemaConfig schema) {
// 路由计算
RouteResultset rrs = null;
try {
// ==== sql 拦截解析 加密 start =======
boolean sqlPass = !sql.contains("information_schema") && (type == ServerParse.DELETE || type == ServerParse.INSERT || type == ServerParse.UPDATE || type == ServerParse.SELECT);
//zrx 如果是增删查改
if (sqlPass) {
// zrx 路由之前修改sql
//zrx 获取是否开启加密
boolean encrypt = YesOrNo.YES.getCode().equals(MycatServer.getInstance().getConfig().getSystem().getEncrypt());
String schemaName = schema.getName();
String[] nodes = schema.getAllDataNodes().toArray(new String[]{});
//获取当前的数据库
MycatConfig conf = MycatServer.getInstance().getConfig();
Map encryptConfigMap = conf.getEncryptConfigMap();
boolean passSchema = encryptConfigMap.containsKey(schemaName);
boolean passDataSource = false;
String dataSource = null;
if (passSchema) {
//如果是同一个 schema 下的,数据库结构都是一样的
for (String node : nodes) {
dataSource = conf.getDataNodes().get(node).getDatabase();
if (encryptConfigMap.get(schemaName).getEncryptDataSourceMap().containsKey(dataSource)) {
passDataSource = true;
}
}
}
boolean shouldEncrypt = encrypt && passSchema && passDataSource;
//zrx 处理加密
if (shouldEncrypt) {
EncryptDataSource encryptDataSource = encryptConfigMap.get(schemaName).getEncryptDataSourceMap().get(dataSource);
//获取需要被加密的表和字段
Map> encryptTableColMap = encryptDataSource.getEncryptTableColMap();
//根据sql类型解析sql
try {
//解析sql
Statement statement = CCJSqlParserUtil.parse(sql);
if (ServerParse.UPDATE == type) {
Update updateStatement = (Update) statement;
Table table = updateStatement.getTable();
String updateTable = table.getName().toLowerCase().replaceAll("`", "");
//别名
Alias alias = table.getAlias();
if (encryptTableColMap.containsKey(updateTable)) {
//获取需要加密的列
Set columns = encryptTableColMap.get(updateTable);
//获取sql中的列
List sqlColumns = updateStatement.getColumns();
List expressions = updateStatement.getExpressions();
if (sqlColumns != null && expressions != null) {
//遍历更新的列,查看是否需要加密
replaceSqlValue(columns, sqlColumns, expressions);
//替换sql
sql = updateStatement.toString();
}
//获取 where 条件
Expression where = updateStatement.getWhere();
if (where != null) {
encryptParser(encryptTableColMap, where, columns, alias == null ? null : alias.getName().toLowerCase().replaceAll("`", ""), updateTable);
sql = updateStatement.toString();
}
}
} else if (ServerParse.INSERT == type) {
Insert insertStatement = (Insert) statement;
String inerstTable = insertStatement.getTable().getName().toLowerCase().replaceAll("`", "");
if (encryptTableColMap.containsKey(inerstTable)) {
//获取需要加密的列
Set columns = encryptTableColMap.get(inerstTable);
//如果insert语句中包含需要加密的表,获取插入的列和值
List sqlColumns = insertStatement.getColumns();
ExpressionList itemsList = (ExpressionList) insertStatement.getItemsList();
if (sqlColumns != null && itemsList != null) {
//遍历插入的列,查看是否有需要加密的
List sqlInsertValues = itemsList.getExpressions();
replaceSqlValue(columns, sqlColumns, sqlInsertValues);
//替换sql
sql = insertStatement.toString();
}
}
} else if (ServerParse.DELETE == type) {
Delete deleteStatement = (Delete) statement;
Table table = deleteStatement.getTable();
String deleteTable = table.getName().toLowerCase().replaceAll("`", "");
//别名
Alias alias = table.getAlias();
if (encryptTableColMap.containsKey(deleteTable)) {
//获取需要加密的列
Set columns = encryptTableColMap.get(deleteTable);
Expression where = deleteStatement.getWhere();
if (where != null) {
encryptParser(encryptTableColMap, where, columns, alias == null ? null : alias.getName().toLowerCase().replaceAll("`", ""), deleteTable);
sql = deleteStatement.toString();
}
}
} else {
Select selectStatement = (Select) statement;
SelectBody body = selectStatement.getSelectBody();
encryptParserSelect(body, encryptTableColMap);
sql = selectStatement.toString();
}
} catch (Exception e) {
LOGGER.error("encrypt sql parser error:", e);
if (!sql.contains("convert(no,SIGNED)")) {
writeErrMessage(ErrorCode.ERR_HANDLE_DATA, "encrypt sql parser error:" + e.toString());
return;
}
}
}
}
// ==== sql 拦截解析 加密 end=======
//生成 rrs 路由对象
rrs = MycatServer
.getInstance()
.getRouterservice()
.route(MycatServer.getInstance().getConfig().getSystem(),
schema, type, sql, this.charset, this);
} catch (Exception e) {
StringBuilder s = new StringBuilder();
LOGGER.warn(s.append(this).append(sql).toString() + " err:" + e.toString(), e);
String msg = e.getMessage();
writeErrMessage(ErrorCode.ER_PARSE_ERROR, msg == null ? e.getClass().getSimpleName() : msg);
return;
}
if (rrs != null) {
// #支持mariadb驱动useBatchMultiSend=true,连续接收到的sql先放入队列,等待前面处理完成后再继续处理。
// 参考https://mariadb.com/kb/en/option-batchmultisend-description/
boolean executeNow = false;
synchronized (this.executeSqlQueue) {
executeNow = this.executeSqlQueue.isEmpty();
this.executeSqlQueue.add(new SqlEntry(sql, type, rrs));
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("add queue,executeSqlQueue size {}", executeSqlQueue.size());
}
}
if (executeNow) {
this.executeSqlId++;
session.execute(rrs, rrs.isSelectForUpdate() ? ServerParse.UPDATE : type);
}
}
}
2.返回结果解密
改写返回结果的代码位于 SingleNodeHandler 和 MultiNodeQueryHandler 的 rowResponse 方法中,在这里我们对返回结果拦截,获取需要解密的数据进行解密,按照 mysql 协议打包重新发送给客户端,核心代码如下:
public void rowResponse(byte[] row, BackendConnection conn) {
....
//zrx处理加密数据
row = ResponseEncryptHandler.getBytes(rrs, session, fieldCount, row);
....
}
public static byte[] getBytes(RouteResultset rrs, NonBlockingSession session, int fieldCount, byte[] row) {
boolean encrypt = YesOrNo.YES.getCode().equals(MycatServer.getInstance().getConfig().getSystem().getEncrypt());
Map encryptConfigMap = MycatServer.getInstance().getConfig().getEncryptConfigMap();
//zrx 处理返回结果
if (encrypt && rrs.getSqlType() == ServerParse.SELECT && encryptConfigMap.containsKey(session.getSource().getSchema())) {
//读取
RowDataPacket resultsetRow = new RowDataPacket(fieldCount, 10);
//解密读取
resultsetRow.readDecrypt(row);
if (!resultsetRow.decryptIndexs.isEmpty()) {
//如果有需要加密的列
List fieldValues = resultsetRow.fieldValues;
for (Integer index : resultsetRow.decryptIndexs) {
//读取加密后的字符串
String encryptHex = new String(fieldValues.get(index), StandardCharsets.UTF_8);
//解密并设置值
fieldValues.set(index, EncryptHelper.decode(EncryptHelper.hexStringToBytes(encryptHex)));
}
ByteBuffer buffer = ByteBuffer.allocate(row.length);
resultsetRow.write(buffer);
buffer.flip();
byte[] bytes = new byte[buffer.remaining()];
buffer.get(bytes, 0, bytes.length);
//改变结果集
row = bytes;
}
}
return row;
}
以上便是核心部分的代码,由于改造的代码很多,没有办法在这里一一介绍,最后改造完毕的代码主要包含如下功能:
- encrypt.xml 配置文件,配置加密相关信息;
- 支持自动对需要加密的字段加密;
- 内置加密算法,支持对加密字段的 like 查询;
- 支持自定义加密算法;
- 支持复杂的 sql 查询,自动对 where 条件中的加密字段加密;
- 增,改数据时自动对需要加密的字段加密;
- 支持 mysql8
使用方法
首先下载安装包(文末获取),解压,进入 conf 文件夹进行 server.xml,schema.xml,encrypt.xml 的配置。
改造后的程序跟 mycat 的使用方法完全一致,在这里只介绍加解密相关的使用,加解密的使用方法也很简单,首先配置 server.xml 的加密相关的属性,如下:
1
1
12345678ABCDEFGH
123456
test
然后配置 schema.xml,这里的配置跟 mycat 完全一致,如:
select user()
最后配置 encrypt.xml,我们配置需要加密的字段为 card_code 和 name,表都为 real_people_copy1,设auto 为 1,启动自动加密:
配置完毕后,进入 bin 目录,启动:
./mycat start
# 出现以下提示代表启动成功
MyCAT Server startup successfully. see logs in logs/mycat.log
使用 navicat 或其他工具连接 mycat 代理服务,端口默认是 8066,连接方式选择 mysql,如果代理的不是 mysql 数据库,使用控制台方式连接:mysql -u root -P8066 -p 123456,因为 mycat 模拟的是 mysql ,如果使用客户端工具连接发送的 sql 请求是基于 mysql 协议发送的,mycat 转发到数据库中执行会出错。
这里以 navicat 为例,配置好连接信息,测试连接,连接成功:
然后我们查看真正的数据库中 real_people_copy1 中的数据,查看 card_code 和 name 字段是否已被加密:
如上所示,card_code 和 name 字段已被成功加密,然后我们通过 mycat 查看该表,发现展示的是解密后的数据(敏感数据部分打码):
然后我们在 mycat 执行 sql 测试,发现成功查询出了结果:
程序中打断点,发现对查询的 card_code 的值都成功做了替换:
增,删,改同样没有问题,在这里就不一一测试了,至此,数据库透明加解密已经成功实现。
监控管理
mycat 的管理端口为 9066,主要用于 io流量,数据库连接,内存,tps,sql 统计等的监控管理,连接方式跟 mysql 一致:mysql -u 用户名 -P 9066 -p 密码,常用指令如 show @@sql,show @@sql.slow,show @@sql.high 等,我在 mycat-web 的基础上改造了一版,精简了功能,修复了已经存在的 bug,兼容 mysql8,效果如下(源码文末获取):
结语
在 mycat 添加透明加解密的功能其实并不容易,首先要彻底读懂源码,然后才能在此基础上进行修改,其次还要对 mysql 的相关协议深入了解,才能对返回结果进行封装,sql 解析同样是个困难的问题,主要是查询语句的解析,说实话是非常复杂的,虽然过程比较坎坷,但中途也学到了不少东西,总归是有所收获。
使用数据库代理中间件必然会对性能造成一定的损失,经测试,通常情况下,在中间件中额外花费的时间大约在 10-30ms 之间,在可以接受的范围之内。
相关源码资料可以通过关注公众号螺旋编程极客
获取,readme 中有更加详细的使用方法,包括自定义加密算法,手动解密数据等,需要的朋友可以自行查阅。
本文到这里就结束了,我们下次再见!
关注公众号螺旋编程极客
可进群一起探讨,大家一起学习,共同进步,同时有海量学习资源领取。
关注公众号 螺旋编程极客
发送 透明加密
获取相关源码资料!