为了防止小程序被人破解以及接口数据泄露,对于反编译,可以使用官方的提供的加固功能。对于接口,虽然强制使用https作为传输通道,但伪造证书就能轻而易举的抓取网络数据,还是会造成泄露信息。那么对接口数据做加密就很有意义。
由于小程序的接口并非都是同一个项目提供,一个个项目去增加加密功能无疑是很麻烦很费劲的。最好的方式还是交给网关来做。在这参考了TLS流程设计一套加密逻辑,可以说非常的完美。
前期准备
网关需要用到 的包 com.google.crypto.tink,作用是提供X25519密钥交换
<dependency>
<groupId>com.google.crypto.tink</groupId>
<artifactId>tink</artifactId>
<version>1.6.1</version>
</dependency>
前端用到
- js-x25519 提供X25519密钥交换
2.crypto-js.js 提供加密功能
如何实现加密呢?
过程分为2个部分,一个密钥交换,每次打开客户端,先与网关做一个密钥交换,让客户端和网关都拥有同一个密钥。二是,参数加密,客户端在进行网络请求时对参数和body都进行加密。网关解密传给后端接口,接口返回的数据,则由网关加密返回给客户端。
使用交换密钥的好处是,没有固定密钥,每一个人,每一个打开客户端密钥都不一样。只要客户端代码做了加固,基本上就没办法破解
密钥交换
密钥交换流程
使用X25519秘钥协商的方式产生密钥,每次进入小程序之前,都先请求一个密钥交换接口,相互交换密钥,流程如下图所示,
密钥交换流程
X25519秘钥协商流程为,客户端先随机生成32位的字节作为私钥,然后使用x25519算出公钥。而后客户端将公钥发给网关的密钥交换接口。而网关同样的也生成自己的私钥和公钥,并将公钥返回给客户端。然后会发现,如果客户端和网关分别将自己的私钥与对方的公钥用x25519算法做计算,最后得出的密钥居然是一样的。也就是说,虽然网关和客户端交换了密钥相关数据,但是密钥没有泄露,后续双方就可以使用该密钥加密或解密。
spring cloud gateway 密钥交换代码
暴露一个switch接口,接收来自客户端的公钥
@RestController
public class WebController {
@Autowired
private RedissonReactiveClient redissonReactiveClient;
/**
*
* @param token 用户唯一值,如果可以的话,可以校验token是否存在,在小程序里面,这个值一般为openid
* @param time 客户端当前时间戳
* @param secret 客户端传来的公钥
* @return
*/
@RequestMapping(path = "/switch")
public Mono<String> switchKey(@RequestParam(value = "token", required = false) String token, @RequestParam("time") Long time,
@RequestParam("secret") String secret, ServerWebExchange serverWebExchange) throws NoSuchAlgorithmException, InvalidKeyException {
byte[] privateKey = X25519.generatePrivateKey(); //随机生成一个私钥
byte[] publicKey = X25519.publicFromPrivate(privateKey);//根据私钥算出一个公钥
byte[] secretBytes = Hex.decode(secret);//将客户端传来的私钥hex转成字节形式
byte[] key = X25519.computeSharedSecret(privateKey, secretBytes); //算出一个密钥
String keys0 = Hex.encode(key);
//StoreToken是一个实体类,用来放密钥以及时差
StoreToken storeToken = new StoreToken();
String relKey = Hex.encode(this.getRealKey(keys0, token));//做个SHA256计算算出最终密钥
storeToken.setKey(relKey);
storeToken.setCha(System.currentTimeMillis() - time); //计算出客户端与服务端时差,用于计算接口失效时间
String vKey = Hex.encode(publicKey);//将服务端公钥转成hex值
//保存密钥
return saveRedisStoreToken(token, storeToken).thenReturn(vKey);//将公钥hex值返回给
}
//将密钥保存到redis
public Mono<Void> saveRedisStoreToken(String token, StoreToken storeToken) {
String key = "SwitchKey:" + token;
RBucketReactive<Object> bucketReactive = redissonReactiveClient.getBucket(key);
return bucketReactive.set(JsonUtil.ObjectToString(storeToken), 2, TimeUnit.DAYS);
}
//将计算出的密钥与用户ID以及key值混合算sha256,key最好写进配置文件
private byte[] getRealKey(String key, String token) throws NoSuchAlgorithmException {
StringBuffer sb = new StringBuffer();
sb.append(key).append(token).append("kkkk12345");
return SHA256(sb.toString().getBytes());
}
//计算sha256
public static byte[] SHA256(byte[] data) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update(data);
return md.digest();
}
}
客户端密钥交换代码
为了方便,我这边使用浏览器的js来写客户端
const getRandomValue = (num) => window.crypto.getRandomValues(new Uint8Array(num)) //生成随机的byte数组
const toHexString = bytes => bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), ''); //byte数组转hex字符串
const fromHexString = hexString => new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));//hex字符串转byte数组
const privateKey = getRandomValue(32)//随机生成32位私钥
const publicKey = X25519.getPublic(privateKey)//私钥生成公钥
const tokenId = "abc1234";//tokenId可以看作用户ID
const cryptoKey = "kkkk12345";//与网关保持一致,用于对交换密钥进一步转换
window.fetch("http://127.0.0.1:8081/switch?time=" + new Date().getTime() + "&secret=" + toHexString(publicKey), {headers: {"X-Token-Id": tokenId}}).then((v) => v.text()).then((v) => {
const serverPublicKey = fromHexString(v)
const key = X25519.getSharedKey(privateKey, serverPublicKey) //生成密钥
const perKey = toHexString(key) + tokenId + cryptoKey;//将计算出的密钥与用户ID以及key值混合,这样即便是被抓包,也无法推算出最终密钥
const cryKey = CryptoJS.SHA256(perKey).toString()//计算sha256,得出最终密钥
})
数据加密
通过密钥交换获得密钥后,下一步就是加密。由于客户端和网关拥有一样的密钥,那只要使用对称加密就可以了
客户端加解密代码
const cryptoVi = "abcde_tvqwertyui"; //用于加密的vi,与网关保持一致必须是8的倍数
const stringToUint8Array = (str)=>new Uint8Array(str.match(/./g).map(v=>v.charCodeAt(0))); //将字符串转bytes
//加解密参数
const option = {
iv: CryptoJS.enc.Hex.parse(toHexString(stringToUint8Array(cryptoVi))),
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}
//加密方法,用于加密提交的数据
const encryptText = (text, aesKey) => {
const encryptText = CryptoJS.AES.encrypt(text, CryptoJS.enc.Hex.parse(aesKey), option)
return encryptText.ciphertext.toString();
}
//解密方法,用于解密服务端返回的数据
const decryptText = (text, aesKey) => {
const decryptText = CryptoJS.AES.decrypt(CryptoJS.enc.Base64.stringify(CryptoJS.enc.Hex.parse(text)), CryptoJS.enc.Hex.parse(aesKey), option)
return decryptText.toString(CryptoJS.enc.Utf8);
}
//get请求加密
const getRequest = (url, tokenId, cryptoKey) => {
const urlParts = new URL(url);
const searchParams = new URLSearchParams(urlParts.search);
// 无论是否已存在查询参数,都添加时间戳
searchParams.append('time', new Date().getTime())
const query = encryptText(searchParams.toString(), cryptoKey)
const queryUrl = urlParts.href + "?" + query;
return window.fetch(queryUrl, {headers: {"X-Token-Id": tokenId}}).then((v) => v.text()).then((v) => decryptText(v, cryptoKey));//对返回数据解密
}
//post请求加密,加入body为json
const postJsonRequest = (url,jsonString, tokenId,cryptoKey) => {
const urlParts = new URL(url);
const searchParams = new URLSearchParams(urlParts.search);
// 无论是否已存在查询参数,都添加时间戳
searchParams.append('time', new Date().getTime())
const query = encryptText(searchParams.toString(), cryptoKey)
jsonString = encryptText(jsonString, cryptoKey)
const queryUrl = urlParts.href + "?" + query;
return window.fetch(queryUrl, {method:'POST',body:jsonString,headers: {"X-Token-Id": tokenId,"Content-Type":"application/json"}}).then((v) => v.text()).then((v) => decryptText(v, cryptoKey))//对返回数据解密
}
客户端使用方法
//get请求例子
getRequest("http://127.0.0.1:8081/baidu",tokenId,cryKey).then((v)=>v.text()).then((v)=>{
console.log(v)
})
//post提交json例子
postJsonRequest("http://127.0.0.1:8081/baidu",'{"a":"b"}',tokenId,cryKey).then((v)=>v.text()).then((v)=>{
console.log(v)
})
网关加解密代码
AES加密部分
class SecurityUtil{
private static final String AES = "AES";
/**
* 加密解密算法/加密模式/填充方式
*/
private static final String CIPHER_ALGORITHM = "AES/CBC/PKCS5Padding";
/**
* AES加密
*/
public static byte[] encodeAES(byte[] content,byte[] key,byte[] iv) {
try {
javax.crypto.SecretKey secretKey = new javax.crypto.spec.SecretKeySpec(key, AES);
javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(CIPHER_ALGORITHM);
cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, secretKey,new javax.crypto.spec.IvParameterSpec(iv));
byte[] byteAES = cipher.doFinal(content);
return byteAES;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* AES解密
*/
public static byte[] decodeAES(byte[] content, byte[] key,byte[] iv) {
try {
javax.crypto.SecretKey secretKey = new javax.crypto.spec.SecretKeySpec(key, AES);
javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(CIPHER_ALGORITHM);
cipher.init(javax.crypto.Cipher.DECRYPT_MODE, secretKey,new javax.crypto.spec.IvParameterSpec(iv));
byte[] byteDecode = cipher.doFinal(content);
return byteDecode;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}}
网关过滤器加解密代码
@Component
public class HttpDecryptEncrypt {
private byte[] iv = "abcde_tvqwertyui".getBytes();//加密iv与js客户端保持一致
private String decodeAES(String text, StoreToken storeToken) throws UnsupportedEncodingException {
byte[] decodeData = Hex.decode(text);
byte[] data = SecurityUtil.decodeAES(decodeData, Hex.decode(storeToken.getKey()), iv);
if (data != null && data.length > 0) {
String decodeText = new String(data, utf8Charset);
return decodeText;
}
return "";
}
private String encryptAES(String text, StoreToken storeToken) throws IOException {
byte[] encodeDatas = text.getBytes(utf8Charset);
byte[] data = SecurityUtil.encodeAES(encodeDatas, Hex.decode(storeToken.getKey()), iv);
return Hex.encode(data);
}
//临时缓存密钥
private static final Cache<String, StoreToken> storeTokenCache = Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.MINUTES).expireAfterAccess(1, TimeUnit.MINUTES).maximumSize(10_000).build((k) -> new StoreToken());
//使用spring cloud gateway自带的修改请求body的GatewayFilter
@Autowired
private ModifyResponseBodyGatewayFilterFactory modifyResponseBodyGatewayFilterFactory;
//使用spring cloud gateway自带的修改响应body的GatewayFilter
@Autowired
private ModifyRequestBodyGatewayFilterFactory modifyRequestBodyGatewayFilterFactory;
//引入Redisson
@Autowired
RedissonReactiveClient redissonReactiveClient;
//解密网关过滤器
public GatewayFilter requestDecryption() {
return (exchange, chain) -> {
String token = exchange.getRequest().getHeaders().getFirst("X-Token-Id");//获取用户ID
return getRedisStoreToken(redissonReactiveClient, token).flatMap((storeToken) -> {//从redis异步获取密钥
ServerHttpRequest serverHttpRequest = exchange.getRequest();
storeTokenCache.put(serverHttpRequest.getId(), storeToken);//使用RequestId作为key,临时保存密钥,用于返回数据的时候加密
String query = serverHttpRequest.getURI().getQuery();
String queryUrl;
try {
queryUrl = decodeAES(query, storeToken);//解密参数
} catch (UnsupportedEncodingException e) {
return Mono.error(e);
}
MultiValueMap<String, String> multiValueMap = UrlUtil.parseQuery(queryUrl);
URI newUri = UriComponentsBuilder.fromUri(serverHttpRequest.getURI())
.replaceQueryParams(unmodifiableMultiValueMap(multiValueMap)).build()
.encode(utf8Charset).toUri();//构造新的请求url
ServerHttpRequest updatedRequest = exchange.getRequest().mutate().uri(newUri).build();
HttpMethod httpMethod = serverHttpRequest.getMethod();
ServerWebExchange serverWebExchange = exchange.mutate().request(updatedRequest).build();//重新构造WebExchange
if (httpMethod == HttpMethod.GET || httpMethod == HttpMethod.DELETE || httpMethod == HttpMethod.OPTIONS) {
return chain.filter(serverWebExchange);
} else {
GatewayFilter gatewayFilter = modifyRequestBodyGatewayFilterFactory.apply(c -> c.setRewriteFunction(String.class, String.class, (ex, body) -> {
try {
String bodyString = decodeAES(body, storeToken);//解密post请求中的body参数
return Mono.just(bodyString);
} catch (UnsupportedEncodingException e) {
return Mono.error(e);
}
}));
return gatewayFilter.filter(serverWebExchange, chain);//解密完成后,向后传导
}
});
};
}
//加密网关过滤器,对响应的数据数据做加密
public GatewayFilter ResponseEncrypt() {
return (exchange, chain) -> {
ServerHttpRequest serverHttpRequest = exchange.getRequest();
StoreToken storeToken = storeTokenCache.getIfPresent(serverHttpRequest.getId());//从缓存中获取密钥
if (storeToken != null) {
storeTokenCache.invalidate(serverHttpRequest.getId());//用完就删掉
}
GatewayFilter gatewayFilter = modifyResponseBodyGatewayFilterFactory.apply(c -> c.setRewriteFunction(String.class, String.class, (ex, r) -> {
try {
String body = encryptAES(r, storeToken);//对返回的body数据做加密
return Mono.just(body);
} catch (IOException e) {
return Mono.error(e);
}
}));
return gatewayFilter.filter(exchange, chain);
};
}
}
网关配置
@Autowired
HttpDecryptEncrypt httpDecryptEncrypt;
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
RouteLocatorBuilder.Builder routesBuilder = builder.routes();
return routesBuilder.route("baidu",r->r.path("/baidu")
.filters(f->f.filters(httpDecryptEncrypt.requestDecryption(),httpDecryptEncrypt.ResponseEncrypt()))
.uri("lb://baidu")) //配置百度路径
.build();
}
结尾
未加密时客户端向服务器提交的数据
curl --location 'http://127.0.0.1:8081/baidu?time=1723652017833'
--header 'X-Token-Id: abc1234'
--header 'Content-Type: application/json'
--data '{"a":"1"}'
加密后提交的数据
curl --location 'http://127.0.0.1:8081/baidu?8907359ca4e6b1456e0c57b9ab04d2c69dbc07513b9beaade270f9b56b73bd15'
--header 'X-Token-Id: abc1234'
--header 'Content-Type: application/json'
--data 'aceb0ab5cc9b72e48bc1e684ffcccccf'
可以看到,不管是请求url还是body都是加密状态,而服务端响应的body同样也会做加密。如此api接口就不怕抓包了
打个小广告,如果你需要在项目中加入邮件通知功能:可以试试: (smtp2http)github.com/chuccp/smtp… 该项目,可以将smtp,转为http API形式,提供后台管理功能,可以很方便的管理接收邮件地址,使用go语言开发,可以支持多种平台