加密解密加签验签------接口安全的最后一道防线
密评来了
等保密评(密码应用安全性评估)要求下发。
整改项:
- 用户信息存储:明文 → 加密
- 数据传输:明文 → HTTPS + 加密
- 接口调用:无认证 → 加签验签
- 登录口令:明文传输 → SM3 摘要
加密方案演进
算法研究
加密算法的问题
加密研究
加密算法实现
vc解密算法实现
当时还在做技术预研,试过各种算法:
- RSA 1024/2048
- AES 128/256
- DES/3DES
- MD5/SHA
结论:政务系统要求用国密算法,不能用国际算法。
接口加密
新接口加密功能开发及动态库改写
接口升级,首次引入加密:
c
// 动态库中的加密函数(VC编写)
int EncryptData(
const char* plainText, // 明文
char* cipherText, // 密文(输出)
const char* key // 密钥
);
int DecryptData(
const char* cipherText, // 密文
char* plainText, // 明文(输出)
const char* key // 密钥
);
问题:密钥硬编码在动态库里,每发一个版本都要重新编译。
电子签名
电子签名文件生产函数编写
PDF电子签名,用于医保凭证:
java
public byte[] signPdf(byte[] pdfData, X509Certificate cert, PrivateKey key) {
// 1. 计算PDF摘要
MessageDigest md = MessageDigest.getInstance("SM3");
byte[] digest = md.digest(pdfData);
// 2. 用私钥签名
Signature sig = Signature.getInstance("SM2");
sig.initSign(key);
sig.update(digest);
byte[] signature = sig.sign();
// 3. 嵌入PDF
PdfSigner signer = new PdfSigner();
signer.sign(pdfData, cert, signature);
return signer.getSignedPdf();
}
全面密评改造
1. HTTPS 证书问题
解决认证https不能访问的问题
证书不符合要求
问题:自签名证书被浏览器拦截。
解决:用 OpenSSL 生成合法证书。
bash
# 生成RSA私钥和自签名证书
openssl req -newkey rsa:2048 -nodes -keyout rsa_private.key \
-x509 -days 365 -out cert.crt
# 导出为PFX格式(Tomcat使用)
openssl pkcs12 -export -out certificate.pfx \
-inkey rsa_private.key -in cert.crt
注意:生产环境必须用 CA 颁发的证书,不能自签名。
2. 用户信息加密存储
用户信息加密解密开发、部署
需求:数据库中存储的用户信息(姓名、身份证、手机号)必须加密。
java
// 加密存储方案
public class UserInfoEncryption {
// 存储时加密
public void saveUser(User user) {
String encryptedName = SM4.encrypt(user.getName(), secretKey);
String encryptedIdCard = SM4.encrypt(user.getIdCard(), secretKey);
jdbcTemplate.update(
"INSERT INTO t_user (name, id_card, ...) VALUES (?, ?, ...)",
encryptedName, encryptedIdCard
);
}
// 查询时解密
public User getUser(Long id) {
Map<String, Object> row = jdbcTemplate.queryForMap(
"SELECT * FROM t_user WHERE id = ?", id
);
User user = new User();
user.setName(SM4.decrypt((String)row.get("name"), secretKey));
user.setIdCard(SM4.decrypt((String)row.get("id_card"), secretKey));
return user;
}
}
问题 :加密后无法模糊查询(LIKE '%张%')。
解决:
java
// 方案1:加密字段精确查询
SELECT * FROM t_user WHERE name = SM4.encrypt('张三', key)
// 方案2:增加明文索引字段(脱敏后)
ALTER TABLE t_user ADD name_index VARCHAR(50);
// name_index = "张**"(只存姓氏,不存全名)
// 方案3:使用可搜索加密(Searchable Encryption)
// 但国密不支持,暂不采用
3. 接口加签验签
加密、解密、加签、验签测试
加密、解密、加签、验签方法确定、代码编写、更新数据、查询数据测试
完整方案:
java
public class ApiSecurity {
// ========== 加签(发送方) ==========
public String signRequest(Map<String, String> params, String secretKey) {
// 1. 参数排序
TreeMap<String, String> sortedParams = new TreeMap<>(params);
// 2. 拼接字符串
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");
}
sb.append("key=").append(secretKey);
// 3. SM3 摘要
byte[] digest = SM3.digest(sb.toString().getBytes(StandardCharsets.UTF_8));
// 4. 转十六进制字符串
return Hex.encodeHexString(digest);
}
// ========== 验签(接收方) ==========
public boolean verifySign(Map<String, String> params, String sign, String secretKey) {
// 1. 去除签名字段
Map<String, String> paramsWithoutSign = new HashMap<>(params);
paramsWithoutSign.remove("sign");
// 2. 重新计算签名
String calculatedSign = signRequest(paramsWithoutSign, secretKey);
// 3. 比对
return calculatedSign.equals(sign);
}
// ========== 接口加密 ==========
public String encryptRequest(String data, String sessionKey) {
// 1. 生成随机密钥
byte[] randomKey = SM4.generateKey();
// 2. 用会话密钥加密随机密钥
byte[] encryptedKey = SM4.encrypt(randomKey, sessionKey);
// 3. 用随机密钥加密数据
byte[] encryptedData = SM4.encrypt(data.getBytes(), randomKey);
// 4. 组装
return Base64.encode(encryptedKey) + "." + Base64.encode(encryptedData);
}
}
验签流程:
请求方 接收方
│ │
│ 1. 参数排序 │
│ 2. 拼接 key=value&key=value │
│ 3. SM3 摘要 │
│ 4. 得到签名 sign │
│ │
│ ──── 请求参数 + sign ──────▶ │
│ │
│ │ 1. 去除 sign 字段
│ │ 2. 同样方式计算签名
│ │ 3. 比对两个签名
│ │
│ ◀──── 响应 + sign ────────── │
│ │
│ 验签响应 │
4. 登录口令密文传输
登录口令密文传输改造(历史数据查询)
前端sm3算法加密口令(实际为计算摘要)
后端解密存储在数据中的口令,再sm3算法加密
比较是否相同
改造前:
javascript
// 登录时明文传输
$.ajax({
url: "/login",
data: {
username: "admin",
password: "123456" // 明文!抓包就能看到
}
});
改造后:
javascript
// 前端:SM3 摘要后传输
async function login(username, password) {
// 1. 获取随机盐值
const salt = await getSalt(username);
// 2. 计算 SM3(password + salt)
const hash = sm3(password + salt);
// 3. 传输摘要(不是原文)
$.ajax({
url: "/login",
data: {
username: username,
passwordHash: hash,
salt: salt
}
});
}
java
// 后端:验证逻辑
public boolean verifyPassword(String username, String passwordHash, String salt) {
// 1. 从数据库取出加密存储的口令
String storedPassword = userDao.getPassword(username);
// 2. 解密存储的口令(SM4解密)
String decryptedPassword = SM4.decrypt(storedPassword, secretKey);
// 3. 计算 SM3(解密口令 + salt)
String expectedHash = SM3.digest(decryptedPassword + salt);
// 4. 比对
return expectedHash.equals(passwordHash);
}
国密算法选择
为什么用国密?
等保密评要求:政务系统必须使用国密算法。
算法对照表
| 用途 | 国际算法 | 国密算法 | 说明 |
|---|---|---|---|
| 对称加密 | AES | SM4 | 分组加密,128位密钥 |
| 非对称加密 | RSA | SM2 | 基于椭圆曲线,256位密钥 |
| 摘要 | SHA-256 | SM3 | 256位摘要 |
| 随机数 | DRBG | SM3派生 | 用于生成密钥 |
实际使用对比
java
// AES 加密(旧)
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, aesKey, iv);
byte[] encrypted = cipher.doFinal(plainText);
// SM4 加密(新)
SMS4 sm4 = new SMS4();
byte[] encrypted = sm4.encrypt(plainText, sm4Key, sm4Iv);
SM2 vs RSA 性能
| 算法 | 密钥生成 | 签名(100次) | 验签(100次) | 安全性 |
|---|---|---|---|---|
| RSA-2048 | 慢(2s) | 慢(1s) | 快(0.3s) | 中等 |
| SM2-256 | 快(0.1s) | 快(0.3s) | 慢(1s) | 高(同等RSA-3072) |
性能问题
加密对性能的影响
测试数据:
- 100万条用户信息加密存储
- SM4加密每条耗时:0.1ms
- SM4解密每条耗时:0.1ms
- 总耗时:100万 × 0.1ms = 100秒
优化后:
- 批量加密:1000条/批
- 并行处理:4线程
- 总耗时:100万 × 0.1ms / 4 / 1000 = 25秒
查询性能对比
| 操作 | 明文 | 加密后 | 下降 |
|---|---|---|---|
| 精确查询 | 2ms | 5ms | 2.5x |
| 范围查询 | 10ms | 无法 | - |
| 模糊查询 | 15ms | 无法 | - |
| 批量查询 | 5ms | 15ms | 3x |
教训:加密不是免费的,需要为性能下降买单。
常见坑
坑1:编码不一致
java
// 前端JavaScript
const hash = sm3("张三");
// 输出:e8d7...(UTF-8编码)
// 后端Java
byte[] digest = SM3.digest("张三".getBytes(StandardCharsets.UTF_8));
String hash = Hex.encodeHexString(digest);
// 输出:e8d7...(一致)
// 如果后端用了GBK
byte[] digest = SM3.digest("张三".getBytes("GBK"));
// 输出:f3a2...(不一致!)
坑2:Base64 vs Hex
java
// Base64:大小写敏感,有+和/字符
// Hex:只含0-9a-f,URL安全
// 建议:传输用Base64URL(无+无/无=)
String safe = Base64.getUrlEncoder().withoutPadding().encodeToString(data);
坑3:密钥泄露
# 密钥被上传到Git仓库
git add .
git commit -m "add config"
git push
# 密钥文件被公开!
# 预防措施
echo "*.key" >> .gitignore
echo "keystore.*" >> .gitignore
坑4:证书过期
# 证书过期,服务不可用
javax.net.ssl.SSLHandshakeException:
sun.security.validator.ValidatorException:
PKIX path validation failed:
java.security.cert.CertPathValidatorException:
validity check failed
# 监控证书过期时间
openssl x509 -in cert.pem -noout -enddate
# 设置提前30天告警
经验教训
1. 加密不是万能的
- 加密解决的是存储和传输安全
- 业务安全需要权限控制、审计日志配合
- 数据脱敏和加密是两回事
2. 国密算法生态不完善
- Java 默认不支持 SM2/SM3/SM4,需要 BouncyCastle
- 部分数据库不支持加密函数
- 硬件加密机(HSM)兼容性差
3. 性能损耗不可忽视
- 加密后无法做数据库内计算
- 模糊查询需要额外设计
- 批量操作需要分页
4. 密钥管理是最薄弱的环节
最常见的密钥管理问题:
1. 密钥硬编码 → 泄露
2. 密钥定期轮换 → 旧数据无法解密
3. 密钥多环境 → 开发、测试、生产混用
4. 密钥备份 → 丢失后数据永久丢失
最后的话
加密解密加签验签,看起来是纯技术问题,实际上是一个管理问题。
技术上,SM2/SM3/SM4 算法都是现成的,BouncyCastle 库也成熟。真正难的是:
- 密钥怎么管------谁持有密钥?泄露了怎么办?
- 性能怎么保------加密后查询慢了10倍,业务能接受吗?
- 兼容怎么搞------旧数据没加密,新数据加密了,怎么平滑过渡?
最实在:
加密、解密、加签、验签方法确定、代码编写、更新数据、查询数据测试
"方法确定"放在"代码编写"前面------先想清楚再做,加密这事,一步错步步错。