基于 mycat,我实现了一个数据库透明加密中间件

前言

数据是信息系统的核心资产,数据安全现在越来越受到企业的重视,虽然在数据库外围能够很大程度上防止数据泄露,但核心数据的安全容不得半点差错,数据库加密因此应运而生,它作为数据安全的最后一道防线,确保即使数据库被攻陷,存储在数据库中的数据仍然可以得到有效保护。数据库加密主要用来解决以下两个方面的问题: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<String, EncryptServer> 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<String, Set<String>> 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<String> columns = encryptTableColMap.get(updateTable);
                                //获取sql中的列
                                List<Column> sqlColumns = updateStatement.getColumns();
                                List<Expression> 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<String> columns = encryptTableColMap.get(inerstTable);
                                //如果insert语句中包含需要加密的表,获取插入的列和值
                                List<Column> sqlColumns = insertStatement.getColumns();
                                ExpressionList itemsList = (ExpressionList) insertStatement.getItemsList();
                                if (sqlColumns != null && itemsList != null) {
                                    //遍历插入的列,查看是否有需要加密的
                                    List<Expression> 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<String> 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<String, EncryptServer> 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<byte[]> 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;
    }

以上便是核心部分的代码,由于改造的代码很多,没有办法在这里一一介绍,最后改造完毕的代码主要包含如下功能:

  1. encrypt.xml 配置文件,配置加密相关信息;
  2. 支持自动对需要加密的字段加密;
  3. 内置加密算法,支持对加密字段的 like 查询;
  4. 支持自定义加密算法;
  5. 支持复杂的 sql 查询,自动对 where 条件中的加密字段加密;
  6. 增,改数据时自动对需要加密的字段加密;
  7. 支持 mysql8

使用方法

首先下载安装包(文末获取),解压,进入 conf 文件夹进行 server.xml,schema.xml,encrypt.xml 的配置。

改造后的程序跟 mycat 的使用方法完全一致,在这里只介绍加解密相关的使用,加解密的使用方法也很简单,首先配置 server.xml 的加密相关的属性,如下:

<!--是否启用加密 1 表示启用,会根据 encrypt.xml 中的配置自动加解密-->
<property name="encrypt">1</property>
<!--是否使用内置的加密算法 1-是 0-否 -->
<!--如果要自定义加密算法,下载源码 mycat-encrypt-customize 自行实现 encode 和 decode 方法,打包替换 encrypt-core-2.0.jar 即可-->
<property name="useInternalEncryptAlgorithm">1</property>
<!--内置加密算法的密钥(自定义16位字符串) 如果自定义加密算法,则不需要配置-->
<property name="secretKey">12345678ABCDEFGH</property>

<!--配置用户名和密码以及能查看的逻辑库,多个逻辑库用逗号分隔,连接 mycat 使用这里配置的用户名和密码-->
<user name="root">
        <property name="password">123456</property>
        <property name="schemas">test</property>

        <!-- 表级 DML 权限设置 -->
        <!--
        <privileges check="false">
            <schema name="TESTDB" dml="0110" >
                <table name="tb01" dml="0000"></table>
                <table name="tb02" dml="1111"></table>
            </schema>
        </privileges>
         -->
    </user>

然后配置 schema.xml,这里的配置跟 mycat 完全一致,如:

 <!-- 逻辑库配置 -->
<schema name="test" checkSQLschema="true" sqlMaxLimit="100" dataNode="test">
        <!-- auto sharding by id (long) -->
    </schema>
    <!--<schema name="oracle" checkSQLschema="true" sqlMaxLimit="100" dataNode="oracle">
    </schema>-->
     <!-- 数据节点 -->
    <dataNode name="test" dataHost="test" database="test" />
    <!--<dataNode name="oracle" dataHost="oracle" database="oracle" />-->

    <!-- 真实物理节点配置; dbDriver:mysql使用native,其他数据库使用jdbc -->
    <dataHost name="test" maxCon="1000" minCon="10" balance="0"
              writeType="0" dbType="mysql" dbDriver="native" switchType="1"  slaveThreshold="100">
        <heartbeat>select user()</heartbeat>
        <!-- can have multi write hosts -->
        <writeHost host="hostM1" url="localhost:3307" user="root"
                   password="root">
        </writeHost>
    </dataHost>

    <!--<dataHost name="oracle" maxCon="1000" minCon="10" balance="0"
              writeType="0" dbType="oracle" dbDriver="jdbc" switchType="1"  slaveThreshold="100">
        <heartbeat>select 1 from dual</heartbeat>
        &lt;!&ndash; can have multi write hosts &ndash;&gt;
        <writeHost host="hostM1" url="jdbc:oracle:thin:@127.0.0.1:1521/orcl" user="oracle"
                   password="123456">
        </writeHost>
    </dataHost>-->

最后配置 encrypt.xml,我们配置需要加密的字段为 card_code 和 name,表都为 real_people_copy1,设auto 为 1,启动自动加密:

<!--auto 属性目前只适用于 mysql 数据库-->
    <!--解析小于等于以下复杂度sql基本是没问题的,如果被加密的字段起别名了,目前不支持,常用的 sql 足够了,如果遇到查询条件不能正常加密的,可以在程序中控制一下,但应该是极少数
    SELECT * FROM (SELECT * FROM real_people
    WHERE card_code='420503197007051836'
    UNION ALL
    SELECT rp.* FROM real_people rp LEFT JOIN real_people p ON rp.people_id=p.people_id
    WHERE rp.card_code='420503197007051836' AND p.card_code LIKE '420503197007051836' AND rp.card_code IN (SELECT * FROM (SELECT card_code FROM real_people WHERE card_code ='420503197007051836') t WHERE t.card_code <> '420503197007051836') AND rp.card_code IN ('420503197007051836')) t WHERE t.card_code='420503197007051836'
    AND t.card_code IN (SELECT card_code FROM real_people WHERE card_code LIKE '%42050319700705183%') -->

    <!--加密的此处的 schema 对应 schema.xml 中的schema schema保持唯一,不能重复-->
    <server schema="test">
         <!--datasource 对应 schema.xml 中对应 schema 下的 dataNode 对应的 datasource,auto 为 1 则会自动加密-->
        <datasource name="test" ip="localhost" port="3307" username="root" password="root" auto="1" dbType="mysql">
            <!--被加密的字段需要是字符串类型-->
            <column name="card_code">
                <!--表名和主键-->
                <table name="real_people_copy1" pk="people_id"/>
            </column>
            <column name="name">
                <table name="real_people_copy1" pk="people_id"/>
            </column>
        </datasource>
    </server>

配置完毕后,进入 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,效果如下(源码文末获取):


性能

sql

结语

在 mycat 添加透明加解密的功能其实并不容易,首先要彻底读懂源码,然后才能在此基础上进行修改,其次还要对 mysql 的相关协议深入了解,才能对返回结果进行封装,sql 解析同样是个困难的问题,主要是查询语句的解析,说实话是非常复杂的,虽然过程比较坎坷,但中途也学到了不少东西,总归是有所收获。

使用数据库代理中间件必然会对性能造成一定的损失,经测试,通常情况下,在中间件中额外花费的时间大约在 10-30ms 之间,在可以接受的范围之内。

相关源码资料可以通过关注公众号螺旋编程极客获取,readme 中有更加详细的使用方法,包括自定义加密算法,手动解密数据等,需要的朋友可以自行查阅。

本文到这里就结束了,我们下次再见!

关注公众号螺旋编程极客可进群一起探讨,大家一起学习,共同进步,同时有海量学习资源领取。
关注公众号 螺旋编程极客 发送 透明加密 获取相关源码资料!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,445评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,889评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,047评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,760评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,745评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,638评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,011评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,669评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,923评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,655评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,740评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,406评论 4 320
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,995评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,961评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,023评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,483评论 2 342

推荐阅读更多精彩内容