在对安全十分敏感的业务系统中,例如保存客户敏感信息、存储系统核心配置等场景,实现数据库加密存储是非常常见且有必要的安全措施。这篇文章基于MySQL数据库和Java实现的服务端程序(使用Spring和MyBatis框架)作为一个例子,介绍如何实现数据库字段级的加密存储以及数据加密字段的记录关联、模糊匹配功能。
实际上,数据库实现加密功能有两种实现思路。
存储引擎层加密存储:存储引擎层加密通常由数据库的存储引擎实现,这种加密存储方式意味着数据库中所有数据在磁盘上存储时,写入磁盘的内容本身就是密文,它通常对应用层是透明的,这意味着我们使用类似Navicat软件、JDBC等方式读取到的数据仍是明文,我们可以像操作明文数据库一样操作这个加密的数据库。
应用层字段级加密存储:应用层字段级加密存储是应用程序本身或调用数据库的加解密函数实现的,这种加密方式将密文字段存入数据库表,读取数据时,解密也要由应用层自行完成,这也意味着开发应用层字段级加密存储功能通常对代码实现是“侵入式”的,我们要编写额外的数据字段加解密逻辑实现数据的存取。
存储引擎层加密存储通常是数据库服务器软件实现的,而应用层字段级加密存储是应用程序本身实现的,以上这两种数据库加密方式并不是互斥的,甚至需要结合使用。存储引擎层加密存储的意义在于避免服务器文件系统被黑客入侵时,数据库在磁盘上的文件被窃取后,黑客能直接查看数据库文件来获取信息;而应用层字段级加密存储则是为了规避应用程序本身的Bug(如SQL注入漏洞)造成数据泄露后,明文数据记录被黑客直接读取(不要天真的认为使用Hibernate等ORM框架就能避免SQL注入,大型的业务系统通常都是一个团队开发的,其中有老手也有新手,误用动态查询、原生SQL等造成SQL注入点的情况几乎不可能彻底避免)。
我们这里主要介绍后者如何实现以及相关的实践案例。
为了不影响数据库中SQL语句对加密字段的处理,我们最好围绕MySQL数据库支持的几种加解密函数来编写应用层代码。MySQL数据库支持AES-128对称加密算法的加解密函数,支持ECB、CBC、CFB和OFB模式,不过由于ECB不使用随机向量,其在安全性上有一定的缺点,因此不推荐使用,我们这里选择CBC模式实现加解密。此外,MySQL的加解密函数使用PKCS5填充模式,我们的Java应用层代码也需要对应设置。
我们这里创建一个数据表t_customer
,其中我们要加密的字段是客户的身份证信息,加密的密文存储在identity
字段中,而identity_iv
是本次加密使用的随机向量,它由应用层生成。
密文字段我们打算使用Base64字符串存储,中国公民的身份证最多为18位,但AES加密和Base64编码操作可能让数据变得更长,因此我们选择了VARCHAR(255)
类型存储该字段;而随机向量IV在AES-128中固定是16字节(128Bit)的,因此我们使用CHAR(32)
字段类型固定存储IV的Hex值。
create table t_customer
(
id bigint auto_increment comment '主键' primary key,
name varchar(50) not null comment '客户姓名',
age int not null comment '客户年龄',
identity varchar(255) not null comment '客户身份证号(加密)',
identity_iv char(32) not null comment '客户身份证号字段IV'
) comment '客户表';
注意:
在应用层的Java代码中,我们最好围绕MySQL支持的加密函数来编写。下面例子实现了AES-128对称加密算法的CBC模式加解密,其中还配置了PKCS5的填充模式。
package com.gacfox.demo.util;
import com.gacfox.demo.model.EncryptionResult;
import org.apache.commons.codec.binary.Hex;
import org.springframework.util.StringUtils;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
/**
* 字段级数据加密工具类
*/
public class DatabaseEncryptionUtil {
private static final byte[] K;
static {
String encp = System.getProperty("app.encryptor.encp");
try {
K = Arrays.copyOf(encp.getBytes(StandardCharsets.US_ASCII), 16);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 数据加密函数
*
* @param data 明文
* @return 加密结果对象
*/
public static EncryptionResult encrypt(String data) {
if (!StringUtils.hasText(data)) {
return null;
}
// 初始化密钥
SecretKey secretKey = new javax.crypto.spec.SecretKeySpec(K, "AES");
// 初始化随机向量
byte[] iv = new byte[16];
SecureRandom random = new SecureRandom();
random.nextBytes(iv);
IvParameterSpec ivSpec = new IvParameterSpec(iv);
try {
// 初始化加密器
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec);
// 加密并转为Base64形式
byte[] encryptedBytes = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
String encryptedStr = Base64.getEncoder().encodeToString(encryptedBytes);
// 返回加密结果和IV
return EncryptionResult.builder()
.encryptedData(encryptedStr)
.iv(Hex.encodeHexString(iv))
.build();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 解密函数
*
* @param encData 密文Base64字符串
* @param iv 加密时使用的随机向量
* @return 明文
*/
public static String decrypt(String encData, String iv) {
if (!StringUtils.hasText(encData)) {
return null;
}
// 将Base64转为二进制数据
byte[] encDataBytes = Base64.getDecoder().decode(encData);
// 初始化密钥
SecretKey secretKey = new javax.crypto.spec.SecretKeySpec(K, "AES");
try {
// 获取IV对象
IvParameterSpec ivSpec = new IvParameterSpec(Hex.decodeHex(iv.toCharArray()));
// 初始化解密器
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec);
// 解密并还原为字符串
byte[] decryptedBytes = cipher.doFinal(encDataBytes);
return new String(decryptedBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
EncryptionResult.java
package com.gacfox.demo.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class EncryptionResult implements Serializable {
private String encryptedData;
private String iv;
}
代码中,加密函数encrypt()
将明文数据使用密钥加密为密文同时返回随机向量IV,解密函数decrypt()
使用密钥和IV将密文数据还原为明文。密钥我们这里从JVM启动参数-Dapp.encryptor.encp
中读取的,此外MySQL中AES函数的密钥是采用零填充方式实现的,即短字符串转换为二进制字节后,不足16字节的地方使用零补齐,我们的Java代码也采用完全一致的方式生成密钥。
业务层代码实现就比较简单了,我们使用SpringBoot和MyBatis框架实现,代码结构大致如下。
src/main/java
|_ com.gacfox.demo
|_ service
|_ CustomerService.java
|_ impl
|_ CustomerServiceImpl.java
|_ dao
|_ CustomerMapper.java
|_ model
|_ Customer.java
|_ CustomerDto.java
src/main/resources
|_ mappers
|_ CustomerMapper.xml
CustomerService.java
package com.gacfox.demo.service;
import com.gacfox.demo.model.CustomerDto;
/**
* 客户数据维护
*/
public interface CustomerService {
/**
* 添加客户
*
* @param customerDto 客户对象
*/
void addCustomer(CustomerDto customerDto);
/**
* 根据客户ID查询客户
*
* @param id 客户主键
* @return 客户对象
*/
CustomerDto getCustomerById(Long id);
}
CustomerServiceImpl.java
package com.gacfox.demo.service.impl;
import com.gacfox.demo.dao.CustomerMapper;
import com.gacfox.demo.model.Customer;
import com.gacfox.demo.model.CustomerDto;
import com.gacfox.demo.model.EncryptionResult;
import com.gacfox.demo.service.CustomerService;
import com.gacfox.demo.util.DatabaseEncryptionUtil;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
@Service("customerService")
@Transactional(rollbackFor = Exception.class)
public class CustomerServiceImpl implements CustomerService {
@Resource
private CustomerMapper customerMapper;
@Override
public void addCustomer(CustomerDto customerDto) {
Customer customer = new Customer();
BeanUtils.copyProperties(customerDto, customer);
EncryptionResult encryptionResult = DatabaseEncryptionUtil.encrypt(customerDto.getIdentity());
customer.setIdentity(encryptionResult.getEncryptedData());
customer.setIdentityIv(encryptionResult.getIv());
customerMapper.insertCustomer(customer);
}
@Override
public CustomerDto getCustomerById(Long id) {
Customer customer = customerMapper.queryCustomerById(id);
if (customer != null) {
String identity = DatabaseEncryptionUtil.decrypt(customer.getIdentity(), customer.getIdentityIv());
CustomerDto customerDto = new CustomerDto();
BeanUtils.copyProperties(customer, customerDto);
customerDto.setIdentity(identity);
return customerDto;
}
return null;
}
}
CustomerMapper.java
package com.gacfox.demo.dao;
import com.gacfox.demo.model.Customer;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface CustomerMapper {
void insertCustomer(@Param("customer") Customer customer);
Customer queryCustomerById(@Param("id") Long id);
}
CustomerMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gacfox.demo.dao.CustomerMapper">
<insert id="insertCustomer">
insert into t_customer (name, age, identity, identity_iv)
values (#{customer.name}, #{customer.age}, #{customer.identity}, #{customer.identityIv});
</insert>
<select id="queryCustomerById" resultType="Customer">
select id as id, name as name, age as age, `identity` as `identity`, identity_iv as identityIv
from t_customer
where id = #{id};
</select>
</mapper>
Customer.java
package com.gacfox.demo.model;
import lombok.Data;
import java.io.Serializable;
/**
* 客户实体类
*/
@Data
public class Customer implements Serializable {
/**
* 主键
*/
private Long id;
/**
* 客户姓名
*/
private String name;
/**
* 客户年龄
*/
private Integer age;
/**
* 客户身份证号(加密)
*/
private String identity;
/**
* 客户身份证号字段IV
*/
private String identityIv;
}
CustomerDto.java
package com.gacfox.demo.model;
import lombok.Data;
import java.io.Serializable;
/**
* 客户
*/
@Data
public class CustomerDto implements Serializable {
/**
* 主键
*/
private Long id;
/**
* 客户姓名
*/
private String name;
/**
* 客户年龄
*/
private Integer age;
/**
* 客户身份证号
*/
private String identity;
}
JavaEE工程中那些总被诟病为死板的规约在这里发挥了作用,我们使用三层架构(表现层、业务层、持久层)划分了代码中的各个Java类,业务层对外暴露的都是DTO,它们交换明文信息,而持久层处理的都是对应数据表的实体类,它们包含密文和IV字段,数据的加解密就在业务层完成。
代码简单但也有点繁琐,不过这里还是强烈建议你无论使用哪种编程语言和框架,都最好按照上面类似的层次划分开发这类功能,免得因为增加了加密逻辑把类和字段弄得一团乱。
代码开发完后,我们就可以使用单元测试来验证其功能了。最终,我们可能会在数据库t_customer
表中插入类似如下的数据记录。
1 汤姆 18 WRM6VqRL169FdfiNHzjWC7xSfG3hdUTXlzO9ODl2HuA= 3ec056590370618961371330a6f35f4e
我们的Java代码采用了MySQL函数一致的加解密函数,因此实际上Java代码的加解密和MySQL的AES加解密函数是通用的,它们能处理同一个数据记录密文字段,我们这里编写一个解密的例子。
set block_encryption_mode = 'aes-128-cbc';
select convert(aes_decrypt(from_base64(tc.identity), 'abc123', unhex(tc.identity_iv)) using utf8mb4)
from t_customer tc;
SQL语句中,我们首先需要设置解密使用的模式为aes-128-cbc
(它的默认值是aes-128-ecb
),然后我们调用了from_base64()
函数和aes_decrypt()
函数将字段解密,最后调用convert
语句将解密后的二进制数据以utf8mb4
格式文本显示出来。这里我们的密钥是abc123
,MySQL默认采用零填充方式生成16字节AES-128密钥,IV由于我们采用Hex格式存储,因此调用了unhex()
函数将其还原。
至于加密这里就不实现了,MySQL虽然提供了to_base64()
函数和aes_encrypt()
函数实现AES加密,但并没有提供一种简单高效的方式生成随机IV,我们可能需要自定义一段数据库函数逻辑来实现!实际开发中,我们数据的来源通常也是应用程序或Kettle等ETL系统,基本用不到使用SQL函数对明文加密,因此这里就不具体给出一个例子了。
明文被加密为了密文,那么我们还怎么实现Like语句模糊匹配功能呢?其实这也不难,基于前面介绍的MySQL解密函数即可实现,下面是一个例子。
set block_encryption_mode = 'aes-128-cbc';
select *
from t_customer tc
where convert(aes_decrypt(from_base64(tc.identity), 'abc123', unhex(tc.identity_iv)) using utf8mb4) like
'%11010x1199906137045%';
SQL语句中,我们只不过是将明文字段换成了一串函数组成的解密逻辑,Like语句还是和使用明文时一样编写即可。
注意:安全和性能通常是互斥的,上面写法虽然实现了Like模糊匹配,但是它的性能较差,当数据量较大时(例如100万条数据)你会发现这条SQL语句的执行是极慢的,而且任何索引都无法起效!
AES的CBC模式使用了随机向量IV,因此同一个明文的加密结果不同,我们实际开发中应避免将密文字段用作表关联外键。然而有些时候我们还真就必须要用密文字段关联,那我们只能采用类似如下的写法实现。下面例子中,假设我们要将t_customer
表的身份证密文字段和t_customer_remark
表的身份证密文字段关联。
set block_encryption_mode = 'aes-128-cbc';
select *
from t_customer tc
inner join t_customer_remark tcr
on convert(aes_decrypt(from_base64(tc.identity), 'abc123', unhex(tc.identity_iv)) using utf8mb4) = convert(aes_decrypt(from_base64(tcr.identity), 'abc123', unhex(tcr.identity_iv)) using utf8mb4);
和编写Like语句的思路类似,我们需要使用一串解密函数将密文还原为明文再进行Join关联操作,当然,它虽然实现了表关联,但性能不佳,因此我们最好避免这种情况的出现。
此外另一种实现思路是针对明文再额外生成一个哈希列,例如添加一个identity_sha256
字段,它存储了身份证明文的SHA256散列值,我们可以用这个字段实现表关联,这样就避免了在表关联时使用复杂的SQL函数还原明文,但这种做法也引入了额外的不安全性,它可能泄露了数据记录之间的关联性,或是遭到额外的彩虹表攻击。
和生活中存在着许多“不可能三角”一样,实际开发中安全、高性能和稳定性也是一个不可能三角,数据库字段级加密确实有其现实意义,但复杂的加解密逻辑也对系统的稳定性和性能造成了较大的影响,我们要做的就是在三者之间做出妥协和平衡,选择一个最适合当前开发目标的实现方式。