前言
數(shù)據(jù)是信息系統(tǒng)的核心資產(chǎn)泽艘,數(shù)據(jù)安全現(xiàn)在越來越受到企業(yè)的重視欲险,雖然在數(shù)據(jù)庫外圍能夠很大程度上防止數(shù)據(jù)泄露,但核心數(shù)據(jù)的安全容不得半點差錯匹涮,數(shù)據(jù)庫加密因此應(yīng)運而生天试,它作為數(shù)據(jù)安全的最后一道防線,確保即使數(shù)據(jù)庫被攻陷然低,存儲在數(shù)據(jù)庫中的數(shù)據(jù)仍然可以得到有效保護喜每。數(shù)據(jù)庫加密主要用來解決以下兩個方面的問題:1.數(shù)據(jù)庫被拖庫后,避免因為明文存儲導(dǎo)致數(shù)據(jù)泄露雳攘;2.防范內(nèi)部高權(quán)限用戶带兜,竊取數(shù)據(jù)導(dǎo)致數(shù)據(jù)泄露。數(shù)據(jù)加密通常也被叫做數(shù)據(jù)脫敏吨灭,但大多數(shù)系統(tǒng)的數(shù)據(jù)脫敏僅限于應(yīng)用層脫敏刚照,數(shù)據(jù)庫層面存儲的依舊是明文,這始終都是一個安全隱患喧兄。
年前的時候无畔,公司客戶提出了強制性要求啊楚,需要對存儲在數(shù)據(jù)庫的人員信息,比如身份證號檩互,姓名特幔,住址等敏感信息進行數(shù)據(jù)脫敏,之前其實一直有人在研究闸昨,但始終沒有找到好的辦法蚯斯,所以一度處于擱置狀態(tài),現(xiàn)在客戶催的緊饵较,火燒眉毛拍嵌,于是轉(zhuǎn)為由我負責(zé)推動這件事。
最后循诉,經(jīng)過多方對比横辆,研究,討論茄猫,我選擇對 mycat 的源碼進行改造狈蚤,終于實現(xiàn)了數(shù)據(jù)庫透明加解密的需求,可以進行列級別的加密存儲和解密划纽,自研加密算法脆侮,可以支持加密字段的 like 查詢,也可以自定義加解密算法勇劣,對應(yīng)用無感知靖避,無需修改業(yè)務(wù)代碼,目前已在公司投入使用近一個月比默,一直處于穩(wěn)定運行的狀態(tài)幻捏,下面詳細介紹一下。(源碼等資料可于文末獲让馈)
方案選擇
我查閱了一些資料篡九,數(shù)據(jù)庫加密主要有以下幾種主流方案:
1.應(yīng)用系統(tǒng)加密
此種加密方式是最容易想到的,即在編碼層面進行數(shù)據(jù)加密醋奠,對需要加密的字段在存儲或更新的時候加密瓮下,查詢的時候解密,但是改造成本實在過于高昂钝域,而且對業(yè)務(wù)侵入性過強讽坏,日后如果有新的字段加密需求又是一波新的改造。
2.前置代理網(wǎng)關(guān)加密
前置代理網(wǎng)關(guān)本質(zhì)上是數(shù)據(jù)庫代理中間件例证,通過對 sql 攔截解析路呜,對需要加密的字段加密,對響應(yīng)結(jié)果解密,返回給客戶端胀葱,對應(yīng)用系統(tǒng)基本透明漠秋,只需要修改數(shù)據(jù)庫連接配置即可,但由于使用了中間層代理抵屿,對代理的穩(wěn)定性要求較高庆锦,并且不可避免的會造成一定程度的性能損耗。
3.數(shù)據(jù)庫后置加密
某些數(shù)據(jù)庫廠商在數(shù)據(jù)庫引擎層增加了一些擴展接口和擴展機制轧葛,通過這些搂抒,數(shù)據(jù)庫系統(tǒng)用戶可以通過外部接口調(diào)用的方式對數(shù)據(jù)進行加解密處理,對應(yīng)用系統(tǒng)透明尿扯,但支持的數(shù)據(jù)庫類型有限求晶,不支持 mysql。
經(jīng)過公司內(nèi)部討論衷笋,決定采用第二種方案芳杏,當(dāng)然,現(xiàn)在市面上有專門提供數(shù)據(jù)庫加密服務(wù)的廠商辟宗,只要花錢就可以搞定爵赵,但不到萬不得已,不會花錢的泊脐,懂的都懂空幻,不再多說。
自己從頭寫一個數(shù)據(jù)庫代理并不現(xiàn)實晨抡,于是決定站在巨人的肩膀上實現(xiàn)氛悬,現(xiàn)在 Java 領(lǐng)域比較出名的數(shù)據(jù)庫中間件主要是 mycat 和 shardingSphere则剃,雖然二者主打的功能是分庫分表耘柱,但同樣也是優(yōu)秀的數(shù)據(jù)庫代理中間件,其實依本人所見棍现,分庫分表能不用則不用调煎,威力可能很大,但最后數(shù)據(jù)庫也給整殘廢了己肮,維護起來成本過高士袄,而且這種配置方式的分庫分表其實算偽分布式數(shù)據(jù)庫的實現(xiàn),日后如果擴充節(jié)點谎僻,改變分片規(guī)則等會異常麻煩娄柳,還是多從業(yè)務(wù)角度考慮較好一些。
最后艘绍,通過拜讀二者源碼赤拒,發(fā)現(xiàn) mycat 修改起來相對容易一些,shardingSphere 雖然有數(shù)據(jù)脫敏的功能,但經(jīng)過測試挎挖,發(fā)現(xiàn) like 查詢并不支持这敬,復(fù)雜的子查詢 sql 解析不了,仍需完善蕉朵,并且源碼體積異常龐大崔涂,修改難度過高,最后選擇修改 mycat 源碼始衅,增加加解密模塊冷蚂,實現(xiàn)數(shù)據(jù)庫加解密功能。
改造 mycat 實現(xiàn)加解密
最后經(jīng)過各種測試觅闽,決定在 1.6.7.6-release 版本的基礎(chǔ)上進行改造帝雇,改造的部分主要包括:攔截解析 sql 加密,返回結(jié)果解密蛉拙,此外尸闸,為了實現(xiàn)可配置的加解密功能,我添加了 encrypt.xml 配置文件孕锄,在 server.xml 中增加了加解密相關(guān)的屬性吮廉,在下一小節(jié)詳細介紹,在這里我們只關(guān)注核心部分的代碼畸肆,如果大家有興趣宦芦,可以在文末獲取源碼研究。
1.攔截解析 sql 加密
我們需要在 mycat 對 sql 路由之前改寫 sql轴脐,實現(xiàn)加密字段的加密调卑,代碼位于 ServerConnection 的 routeEndExecuteSQL 方法中,我們在這里添加相關(guān)的加密邏輯即可大咱,核心代碼如下:
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[]{});
//獲取當(dāng)前的數(shù)據(jù)庫
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 下的恬涧,數(shù)據(jù)庫結(jié)構(gòu)都是一樣的
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();
//根據(jù)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驅(qū)動useBatchMultiSend=true,連續(xù)接收到的sql先放入隊列,等待前面處理完成后再繼續(xù)處理厦瓢。
// 參考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.返回結(jié)果解密
改寫返回結(jié)果的代碼位于 SingleNodeHandler 和 MultiNodeQueryHandler 的 rowResponse 方法中提揍,在這里我們對返回結(jié)果攔截,獲取需要解密的數(shù)據(jù)進行解密煮仇,按照 mysql 協(xié)議打包重新發(fā)送給客戶端劳跃,核心代碼如下:
public void rowResponse(byte[] row, BackendConnection conn) {
....
//zrx處理加密數(shù)據(jù)
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 處理返回結(jié)果
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);
//解密并設(shè)置值
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);
//改變結(jié)果集
row = bytes;
}
}
return row;
}
以上便是核心部分的代碼,由于改造的代碼很多浙垫,沒有辦法在這里一一介紹刨仑,最后改造完畢的代碼主要包含如下功能:
- encrypt.xml 配置文件强重,配置加密相關(guān)信息;
- 支持自動對需要加密的字段加密贸人;
- 內(nèi)置加密算法间景,支持對加密字段的 like 查詢;
- 支持自定義加密算法艺智;
- 支持復(fù)雜的 sql 查詢倘要,自動對 where 條件中的加密字段加密;
- 增十拣,改數(shù)據(jù)時自動對需要加密的字段加密封拧;
- 支持 mysql8
使用方法
首先下載安裝包(文末獲取)夭问,解壓泽西,進入 conf 文件夾進行 server.xml,schema.xml缰趋,encrypt.xml 的配置捧杉。
改造后的程序跟 mycat 的使用方法完全一致,在這里只介紹加解密相關(guān)的使用秘血,加解密的使用方法也很簡單味抖,首先配置 server.xml 的加密相關(guān)的屬性,如下:
<!--是否啟用加密 1 表示啟用灰粮,會根據(jù) encrypt.xml 中的配置自動加解密-->
<property name="encrypt">1</property>
<!--是否使用內(nèi)置的加密算法 1-是 0-否 -->
<!--如果要自定義加密算法仔涩,下載源碼 mycat-encrypt-customize 自行實現(xiàn) encode 和 decode 方法,打包替換 encrypt-core-2.0.jar 即可-->
<property name="useInternalEncryptAlgorithm">1</property>
<!--內(nèi)置加密算法的密鑰(自定義16位字符串) 如果自定義加密算法粘舟,則不需要配置-->
<property name="secretKey">12345678ABCDEFGH</property>
<!--配置用戶名和密碼以及能查看的邏輯庫熔脂,多個邏輯庫用逗號分隔,連接 mycat 使用這里配置的用戶名和密碼-->
<user name="root">
<property name="password">123456</property>
<property name="schemas">test</property>
<!-- 表級 DML 權(quán)限設(shè)置 -->
<!--
<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>-->
<!-- 數(shù)據(jù)節(jié)點 -->
<dataNode name="test" dataHost="test" database="test" />
<!--<dataNode name="oracle" dataHost="oracle" database="oracle" />-->
<!-- 真實物理節(jié)點配置; dbDriver:mysql使用native嘉抒,其他數(shù)據(jù)庫使用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>
<!– can have multi write hosts –>
<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些侍,設(shè)auto 為 1,啟動自動加密:
<!--auto 屬性目前只適用于 mysql 數(shù)據(jù)庫-->
<!--解析小于等于以下復(fù)雜度sql基本是沒問題的政模,如果被加密的字段起別名了岗宣,目前不支持,常用的 sql 足夠了淋样,如果遇到查詢條件不能正常加密的耗式,可以在程序中控制一下,但應(yīng)該是極少數(shù)
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 對應(yīng) schema.xml 中的schema schema保持唯一,不能重復(fù)-->
<server schema="test">
<!--datasource 對應(yīng) schema.xml 中對應(yīng) schema 下的 dataNode 對應(yīng)的 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
# 出現(xiàn)以下提示代表啟動成功
MyCAT Server startup successfully. see logs in logs/mycat.log
使用 navicat 或其他工具連接 mycat 代理服務(wù)娱挨,端口默認是 8066余指,連接方式選擇 mysql,如果代理的不是 mysql 數(shù)據(jù)庫跷坝,使用控制臺方式連接:mysql -u root -P8066 -p 123456酵镜,因為 mycat 模擬的是 mysql ,如果使用客戶端工具連接發(fā)送的 sql 請求是基于 mysql 協(xié)議發(fā)送的柴钻,mycat 轉(zhuǎn)發(fā)到數(shù)據(jù)庫中執(zhí)行會出錯淮韭。
這里以 navicat 為例,配置好連接信息贴届,測試連接靠粪,連接成功:
然后我們查看真正的數(shù)據(jù)庫中 real_people_copy1 中的數(shù)據(jù),查看 card_code 和 name 字段是否已被加密:
如上所示毫蚓,card_code 和 name 字段已被成功加密庇配,然后我們通過 mycat 查看該表,發(fā)現(xiàn)展示的是解密后的數(shù)據(jù)(敏感數(shù)據(jù)部分打碼):
然后我們在 mycat 執(zhí)行 sql 測試绍些,發(fā)現(xiàn)成功查詢出了結(jié)果:
程序中打斷點捞慌,發(fā)現(xiàn)對查詢的 card_code 的值都成功做了替換:
增,刪柬批,改同樣沒有問題啸澡,在這里就不一一測試了,至此氮帐,數(shù)據(jù)庫透明加解密已經(jīng)成功實現(xiàn)嗅虏。
監(jiān)控管理
mycat 的管理端口為 9066,主要用于 io流量上沐,數(shù)據(jù)庫連接皮服,內(nèi)存,tps参咙,sql 統(tǒng)計等的監(jiān)控管理龄广,連接方式跟 mysql 一致:mysql -u 用戶名 -P 9066 -p 密碼,常用指令如 show @@sql蕴侧,show @@sql.slow择同,show @@sql.high 等,我在 mycat-web 的基礎(chǔ)上改造了一版净宵,精簡了功能敲才,修復(fù)了已經(jīng)存在的 bug裹纳,兼容 mysql8,效果如下(源碼文末獲冉粑洹):
結(jié)語
在 mycat 添加透明加解密的功能其實并不容易剃氧,首先要徹底讀懂源碼,然后才能在此基礎(chǔ)上進行修改阻星,其次還要對 mysql 的相關(guān)協(xié)議深入了解她我,才能對返回結(jié)果進行封裝,sql 解析同樣是個困難的問題迫横,主要是查詢語句的解析番舆,說實話是非常復(fù)雜的,雖然過程比較坎坷矾踱,但中途也學(xué)到了不少東西恨狈,總歸是有所收獲。
使用數(shù)據(jù)庫代理中間件必然會對性能造成一定的損失呛讲,經(jīng)測試禾怠,通常情況下,在中間件中額外花費的時間大約在 10-30ms 之間贝搁,在可以接受的范圍之內(nèi)吗氏。
相關(guān)源碼資料可以通過關(guān)注公眾號螺旋編程極客
獲取,readme 中有更加詳細的使用方法雷逆,包括自定義加密算法弦讽,手動解密數(shù)據(jù)等,需要的朋友可以自行查閱膀哲。
本文到這里就結(jié)束了往产,我們下次再見!
關(guān)注公眾號螺旋編程極客
可進群一起探討某宪,大家一起學(xué)習(xí)仿村,共同進步,同時有海量學(xué)習(xí)資源領(lǐng)取兴喂。
關(guān)注公眾號 螺旋編程極客
發(fā)送 透明加密
獲取相關(guān)源碼資料蔼囊!