在国家对个人隐私越来越看重的现在,很多用户的重要数据都需要加密存储,比如手机号、真实姓名、联系地址等等,但是可能由于系统建设时间久,在建设初期没有考虑这么全面,数据在数据库中都是明文存储。那么如何将数据由明文存储平滑的切换为密文存储呢?
如果项目是新上线,那么可以直接在开发阶段就处理数据加密,没有历史包袱和存量数据需要清洗的问题,实现起来比较简单。以下主要针对一些已上线的业务,需要将明文存储修改为加密存储的场景。
已上线的业务需要将明文存储修改为密文存储,主要需要解决几个问题:
如果项目规模比较小,需要加密的数据表量级较少,并且服务可以允许停机维护,那么可以考虑采取这种方案,直接停机,将数据库备份之后,将需要加密的字段由明文全部清洗为密文,然后发布一个新版本上线,一劳永逸。
优点:
「简单粗暴,没有什么技术复杂度,成本低。」
缺点:
「需要停机维护,停机时间视数据清洗的时间而定。」
「回滚风险,由于直接将明文字段修改为密文,如果发布出现了异常,回滚复杂(需要先停机,然后回滚DB,再回滚版本),可能会丢数据。」
如果项目规模比较大,并且对可用性要求非常高,不允许出现服务不可用的情况下,可以考虑使用Apache ShardingSphere中的Encrypt-JDBC。
❝ Apache ShardingSphere 是一套开源的分布式数据库中间件解决方案组成的生态圈,它提供了多个可插拔的组件,包括数据分片、读写分离、多数据副本、数据加密、影子库压测等功能,可以支持 MySQL、PostgreSQL、SQLServer、Oracle 等 SQL 与协议。github地址 ❞
Encrypt-JDBC通过拦截用户的SQL,并且通过SQL语法解析器来解析SQL,然后进行重写操作后再和数据库进行交互。可以实现对业务代码的透明。他主要提供了几个重要的特性来解决我们上面说到的三个问题。
Encrypt-JDBC会将插入、更新操作时的明文进行加密后进行存储,在查询时先将数据解密后再返回。
这里有一个比较核心的功能是逻辑列(logic cloumn),用户可以配置两个列:cipherColumn和plainColumn。前者用来存储密文数据,后者用来存储明文数据。用户在使用的时候,是使用logicColumn,在配置中配置好logicColumn和对应列的映射关系即可。即对于用户而言,是屏蔽了底层的加密字段和明文字段,只需要使用logic column来查询。这样就可以做到线上的平滑切换和回滚。
❝ 上面的行为都是改写SQL实现的,理论来说用户不需要修改任何原有的SQL,当然,仅仅只是理论上。 ❞
基于上面的特点,我们就可以基本实现线上加密字段的平滑切换。
假设我们有一张用户表(t_user),需要对里面的手机号字段(c_phone)进行加密存储。那么我们可以:
先把t_user表,新增c_phone_encrypt字段。
然后再配置加密规则:
# 配置一个AES加密器,并设置aes.key
encryptRule:
encryptors:
aes_encryptor:
type: aes
props:
aes.key.value: xxxxxx
# 配置需要加密的表
tables:
t_user:
columns:
# 加密表的逻辑字段
c_phone:
# 明文字段
plainColumn: c_phone
# 加密字段
cipherColumn: c_phone_encrypt
# 上面配置的加密器
encryptor: aes_encryptor
# 配置不使用密文查询
props:
query.with.cipher.column: false
接入上面配置后,当t_user表在写入c_phone字段时,会同时写入明文字段(c_phone)和加密字段(c_phone_encrypt),但是在查询时,还是查询明文字段。
接下来需要清洗数据,将历史数据中的c_phone_encrypt字段全部填充为密文。
最后修改配置。
props:
query.with.cipher.column: true
将查询配置修改为根据密文查询,此时写入还是同时写入明文字段和密文字段,但是查询时会查询密文字段(c_phone_encrypt)。如果此时出现了问题需要回滚,将配置回滚即可。
最后,在验证没有问题之后,就可以将明文字段删除,再将配置中的plainColumn移除即可。
上面就是接入ShardingSphere来进行数据加密的过程。通过上面的步骤,我们总结一下它的优点和缺点。
优点:
缺点:
❝ 虽然它会缓存解析结果,但是缓存的key是SQL的签名,比如 select a,b,c from a where id in (?),如果存在大量的参数个数变化的SQL时,每次都需要重新解析。对于QPS比较高的应用来说,一条SQL造成1~2秒的延迟影响的不仅仅是这一个请求,而是整个服务的吞吐量。 ❞
ShardingSphere对于大部分对性能不敏感的应用来说是可以解决数据加密线上平滑迁移的问题的,但是如果需要更好的性能,下面这种方案以业务的侵入性为代价,来提升部分性能。
ShardingSphere里面的思路是非常好的,我们可以参考它的实现,将部分需要语法解析树实现的逻辑,通过编写业务代码的方式来替换它,达到平滑切换的目的。
加解密
加解密使用Mybatis的Alias实现,简单有效
Alias:
/**
* 加密字段,标识更新/插入的加密字段
*/
@Alias("encryptString")
public class EncryptString {
}
/**
* 加密字段,标识查询时的加密字段
*/
@Alias("encryptQuery")
public class EncryptQuery {
}
string encrypt handler:
@MappedTypes(EncryptString.class)
public class StringEncryptTypeHandler extends BaseTypeHandler<String> {
private static final Logger logger = LoggerFactory.getLogger(StringEncryptTypeHandler.class);
private Encryptor encryptor;
public StringEncryptTypeHandler() {
if (MybatisEncryptEnableHolder.isEnabled()) {
//获取加密器
this.encryptor = ServiceLoader.getService(Encryptor.class);
}
}
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String s,
JdbcType jdbcType) throws SQLException {
if (!MybatisEncryptEnableHolder.isEnabled()) {
ps.setString(i, s);
return;
}
if (encryptor.isEncrypt(s)) {
//异常,不应该是加密数据
logger.error("encrypt error {}", s);
}
String encryptValue = encryptor.encrypt(s);
ps.setString(i, encryptValue);
}
@Override
public String getNullableResult(ResultSet rs, String s) throws SQLException {
String value = rs.getString(s);
if (!MybatisEncryptEnableHolder.isEnabled()) {
return value;
}
if (value == null) return null;
try{
return encryptor.isEncrypt(value) ? encryptor.decrypt(value).toString() : value;
}catch (Throwable e) {
return value;
}
}
@Override
public String getNullableResult(ResultSet rs, int i) throws SQLException {
//略
}
@Override
public String getNullableResult(CallableStatement cs, int i) throws SQLException {
//略
}
}
query encrypt handler:
@MappedTypes(EncryptQuery.class)
public class QueryParamEncryptTypeHandler extends BaseTypeHandler<String> {
private Encryptor encryptor;
public QueryParamEncryptTypeHandler() {
if (MybatisEncryptEnableHolder.isEnabled()) {
this.encryptor = ConfigurableServiceLoader.getService(Encryptor.class);
}
}
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String s, JdbcType jdbcType) throws SQLException {
//这里增加了配置,用于切换在查询时,是否需要将查询参数加密
if (!MybatisEncryptEnableHolder.isEnabled() || !MybatisEncryptEnableHolder.isEnabledDynamicColumn()) {
ps.setString(i, s);
return;
}
String encryptValue = encryptor.encrypt(s);
ps.setString(i, encryptValue);
}
@Override
public String getNullableResult(ResultSet rs, String s) throws SQLException {
String value = rs.getString(s);
if (!MybatisEncryptEnableHolder.isEnabled()) {
return value;
}
if (value == null) return null;
try{
return encryptor.isEncrypt(value) ? encryptor.decrypt(value).toString() : value;
}catch (Throwable e) {
return value;
}
}
@Override
public String getNullableResult(ResultSet rs, int i) throws SQLException {
//略
}
@Override
public String getNullableResult(CallableStatement cs, int i) throws SQLException {
//略
}
}
实现拦截器,简单的替换SQL字段,做到动态切换查询字段
Interceptor:
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class DynamicColumnInterceptor implements Interceptor {
private static final Logger log = LoggerFactory.getLogger(DynamicColumnInterceptor.class);
//加密字段和普通字段的映射关系
Map<String, String> encryptColumnMap = new HashMap<>();
//加密的表名
private Set<String> tableNames = new HashSet<>();
public DynamicColumnInterceptor() {
String columnMapStr = ConfigHolder.getProperties().getProperty("encryptColumnMap", "");
this.encryptColumnMap = toMap(columnMapStr);
}
@Override
public Object intercept(Invocation invocation) throws Throwable {
if (MybatisEncryptEnableHolder.isEnabledDynamicColumn()) {
StatementHandler statementHandler = (StatementHandler) realTarget(invocation.getTarget());
MetaObject metaObject = SystemMetaObject.forObject(statementHandler);
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
//如果是查询语句
if (sqlCommandType.equals(SqlCommandType.SELECT)) {
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql();
//如果是加密表
if (containsTable(sql)) {
try{
//改写SQL
String replacedSql = replaceSql(sql);
Field field = boundSql.getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, replacedSql);
}catch (Exception e) {
log.error("替换SQL失败,原SQL:" + sql, e);
}
}
}
}
return invocation.proceed();
}
private boolean containsTable(String sql) {
for (String tableName : tableNames) {
if (sql.contains(tableName)) {
return true;
}
}
return false;
}
private Object realTarget(Object target) {
if (Proxy.isProxyClass(target.getClass())) {
MetaObject metaObject = SystemMetaObject.forObject(target);
return realTarget(metaObject.getValue("h.target"));
} else {
return target;
}
}
/**
* 自己实现replaceAll方法,string.replaceAll中使用正则,性能损耗更高。
*/
private String replaceAll(String str, String key, String value) {
//略,可以参考StringUtils实现,同时考虑字段名部分重复的情况
}
private String replaceSql(String sql) {
String replacedSql = sql;
for (Map.Entry<String, String> entry : encryptColumnMap.entrySet()) {
replacedSql = replaceAll(replacedSql, entry.getKey(), entry.getValue());
}
log.debug("原SQL:{}\n替换后的SQL:{}", sql, replacedSql);
return replacedSql;
}
@Override
public Object plugin(Object o) {
return Plugin.wrap(o, this);
}
@Override
public void setProperties(Properties properties) {
}
}
下面说说具体如何使用。
由于没有做SQL语法树的解析来改写SQL,所以很多操作需要我们在代码中进行,还是以上面的t_user为例:
优点:
缺点:
上面讨论了已上线业务需要做数据加密的几种方案,总的来说,如果对性能没有那么敏感,并且有使用过Sharding-JDBC的经验的话,可以使用ShardingSphere,可以快速的实现方案,但是建议需要花足够的时间来测试和验证。如果本身公司技术实力雄厚,也可以考虑自研语法解析组件来实现高性能和无侵入的加密框架。