传输层加密是指通过加密算法确保数据在传输途中的机密性和完整性。HTTP协议的标准传输层加密方案是借助TLS来实现的,TLS是位于传输层(通常是TCP)和应用层(HTTP)之间的一层,它以一种透明的方式实现了对明文HTTP报文的加密,保证HTTP流量不被窃取或篡改。此外,TLS还基于PKI证书机制确认通信双方身份,避免了中间人攻击的可能性。简而言之,正确配置和使用HTTPS意味着已经实现了基于现代密码学算法的计算安全性。既然TLS对应用层是透明的,我们这里所说的“前后端通信加密”又是怎么一回事呢?
随着国内企业信息安全相关要求的不断加强,很多不懂技术的管理人员不理解TLS和HTTPS的底层机制和作用,当他们安排一些同样不靠谱的“技术人员”看到浏览器开发者工具里JSON数据正以“明文”传输时,想当然的认为应该在前后端通信时再次对数据“加密”,他们认为这样更加安全,于是近几年便流行起“前后端通信加密”这一搞笑要求。
前后端通信加密没有理论安全性上的意义。事实上,在TLS已经为前后端通信提供了加密保障的情况下,重复加密并不会带来额外的理论安全性,反而可能引入不必要的复杂性和性能开销。引入这一层加密将使得系统更加复杂,这意味着更多的Bug和性能的下降;此外前后端通信对前后端程序的调试非常不利,这将导致更多的工作量和更长的调试开发周期。
不使用HTTPS即使做前后端通信加密也是裸奔。如果不使用HTTPS仅使用HTTP,前后端通信加密没有任何理论安全性上的意义。你可以模仿TLS使用非对称加密算法交换密钥再用对称加密算法加密通信,或是模仿实现一个证书系统,然而攻击者可以直接篡改你的JavaScript代码逻辑(毕竟这部分仍是明文),前端使用JavaScript实现任何加密和身份认证机制都是徒劳的。
前后端通信加密在反调试上具有一定意义。尽管前后端通信加密没有理论安全性上的意义,但它具有一定程度上的反调试作用,前后端以加密形态的密文传输数据时,使用浏览器的F12开发者工具将无法一眼看出通信的内容,如果一个攻击者打算逆向调试客户端代码和服务端接口的处理逻辑,这种加密形态数据将给攻击者造成迷惑和干扰,提高其逆向调试门槛,这一操作或许真能唬住部分“小白”黑客,因此具有一定意义。
总而言之,通常应对“前后端通信加密”要求的办法就是“让咋办就咋办”,前后端通信内容看起来是唬人的密文即可,小白渗透测试人员会认为这样确实“安全”了并对系统的安全性给出好评,中高级渗透测试人员则会对“前后端通信加密”要求本身一笑了之。
实现这种本就没有意义的“前后端通信加密”最有效的方式就是固定一个对称密钥。这种方式下,前端代码将对称密钥配置在JavaScript代码中,当和服务端通信时,前端使用对称密钥加密数据,服务端解密请求数据并返回加密的响应数据,前端再使用密钥解析数据。由于密钥本身在前端代码里,因此保证密钥不被轻易逆向获取通常需要借助代码混淆器来实现,这种“加密”方案简单有效,虽然不是真的有理论安全性意义,但它能阻碍初级黑客的逆向调试行为,已经能够满足大部分企业的验收要求了。
千万不要设计一个后端接口来将对称密钥直接传输给前端,这样“前后端通信加密”的最后一点反调试意义也没有了,也不要自作聪明的自行用非对称加密算法实现密钥交换(即前端先查询公钥再交换加密的对称密钥),获取公钥这个接口太突兀了,反而成了给逆向调试的提示。如果企业要求必须采用“密钥交换”机制,那么你还是将公钥固定写在JavaScript代码中并用混淆器保护比较好,这样起码还有点反调试意义。
如果有精力推荐自定义一些奇奇怪怪的编解码算法,这些奇怪算法被混淆器混淆后,基本能让大部分小白黑客眼前一黑,放弃逆向调试的主意。
另一个需要考虑的点就是请求体(响应体)整体加密还是字段级的加密,我推荐一定要采用整体加密,这样无论是服务端代码还是客户端代码都可以编写为非侵入式的,开发起来非常方便,下面的例子代码也是采用整体加密方式实现。
对于服务端程序,我们一方面要解密HTTP请求数据,另一方面要加密HTTP响应数据。SpringMVC中,我们可以自定义一种内容协商类型和对应的MessageConverter
,这种写法是最推荐的,它对控制器层的业务逻辑代码是完全透明的,这意味着它对SpringMVC的控制器参数和字段验证机制等没有任何侵入性的影响。
下面例子中,我们实现一个前后端加密传输的POST /api/v1/student/queryStudentByStuCode
接口,它接收查询参数,并返回查询结果。项目使用SpringBoot2.7创建,简要的工程目录结构如下。
|_ src/main/java
|_ com.gacfox.demo
|_ config
|_ EncryptedHttpMessageConverter.java
|_ WebMvcConfigure.java
|_ controller
|_ StudentController.java
|_ util
|_ DataEncryptionUtil.java
EncryptedHttpMessageConverter.java
package com.gacfox.demo.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.gacfox.demo.util.DataEncryptionUtil;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.MediaType;
import org.springframework.http.converter.AbstractHttpMessageConverter;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.HttpMessageNotWritableException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* 自定义加密消息转换器
*/
public class EncryptedHttpMessageConverter extends AbstractHttpMessageConverter<Object> {
private final ObjectMapper objectMapper;
public static final MediaType ENCRYPTED_JSON = new MediaType("application", "encrypted");
public EncryptedHttpMessageConverter(ObjectMapper objectMapper) {
super(ENCRYPTED_JSON);
this.objectMapper = objectMapper;
}
@Override
protected boolean supports(Class<?> clazz) {
return true;
}
@Override
protected Object readInternal(Class<?> clazz, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
int inputBodyLength = inputMessage.getBody().available();
byte[] encryptedDataBytes = new byte[inputBodyLength];
int ignored = inputMessage.getBody().read(encryptedDataBytes);
String encryptedData = new String(encryptedDataBytes, StandardCharsets.UTF_8);
String oJson = DataEncryptionUtil.decrypt(encryptedData);
return objectMapper.readValue(oJson, clazz);
}
@Override
protected void writeInternal(Object o, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
String oJson = objectMapper.writeValueAsString(o);
String oJsonEncrypted = DataEncryptionUtil.encrypt(oJson);
outputMessage.getBody().write(oJsonEncrypted.getBytes(StandardCharsets.UTF_8));
}
}
EncryptedHttpMessageConverter
是我们自定义的消息转换器,它添加了一种自定义的内容协商类型application/encypted
,当HTTP请求头包含对应的内容协商类型时,SpringMVC框架就知道需要调用我们自定义的这个消息转换器解析请求和处理响应。
WebMvcConfigure.java
package com.gacfox.demo.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
import java.util.List;
/**
* SpringMVC配置
*/
@Configuration
public class WebMvcConfigure implements WebMvcConfigurer {
@Resource
private ObjectMapper objectMapper;
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
// 添加自定义加密消息转换器
converters.add(new EncryptedHttpMessageConverter(objectMapper));
}
}
WebMvcConfigure
中我们向框架注册了自定义的消息转换器。
DataEncryptionUtil.java
package com.gacfox.demo.util;
import org.springframework.util.StringUtils;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
/**
* 数据加解密工具
*/
public class DataEncryptionUtil {
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);
}
}
public static String encrypt(String data) {
if (!StringUtils.hasText(data)) {
return data;
}
SecretKey secretKey = new javax.crypto.spec.SecretKeySpec(K, "AES");
try {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
byte[] encryptedBytes = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encryptedBytes);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static String decrypt(String encData) {
if (!StringUtils.hasText(encData)) {
return encData;
}
byte[] encDataBytes = Base64.getDecoder().decode(encData);
SecretKey secretKey = new javax.crypto.spec.SecretKeySpec(K, "AES");
try {
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, secretKey);
byte[] decryptedBytes = cipher.doFinal(encDataBytes);
return new String(decryptedBytes, StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
DataEncryptionUtil
类基于AES-128对称加密算法实现了通用的加密和解密方法,我们这里将从JVM启动参数-Dapp.encryptor.encp
加载密钥,由于AES-128密钥是16字节的,我们这里将密钥中不足16字节的部分用零填充,你也可以使用其他方式加载或生成AES密钥或是换用其它对称加密算法,这里不是重点。我们主要关注encrypt()
和decrypt()
两个方法,前者将字符串数据加密为二进制数据并编码为Base64,后者将Base64数据解码为密文,然后使用加密算法解密为明文字符串。
此外,这里使用的AES加解密方式是ECB模式。我当然知道ECB模式不如CFB等模式安全,但既然前面已经说过,“前后端通信加密”本就是没有理论安全性上的意义的,使用最简单的ECB模式还能省去存取IV的麻烦,毕竟能提出“前后端通信加密”这种搞笑需求的客户也对现代密码学算法的细节没什么了解,问之则答曰:“我们用的AES加密算法,非常安全!(什么ECB,俺不知道)”即可。
注:其实直接传输二进制效率更高,Base64编码会让数据的体积变大,但后面会在前端用到CryptoJS,这个库比较古老,它出现时HTML5的ArrayBuffer还没被定义,CryptoJS使用了自己的WordArray对象,它和ArrayBuffer之间转换起来比较麻烦,所以这里就用CryptoJS直接支持的Base64编码了。如果你使用不同的前端加密库,推荐尝试下直接传输二进制数据。
StudentController.java
package com.gacfox.demo.controller;
import com.gacfox.demo.model.ApiResult;
import com.gacfox.demo.model.QueryStudentDto;
import com.gacfox.demo.model.StudentDto;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
/**
* 测试接口
*/
@RestController
@RequestMapping("/api/v1/student")
public class StudentController {
@PostMapping(value = "/queryStudentByStuCode",
consumes = "application/encrypted", produces = "application/encrypted")
public ApiResult<?> queryStudentByStuCode(@RequestBody @Valid QueryStudentDto queryStudentDto,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
FieldError fieldError = bindingResult.getFieldError();
return ApiResult.failure("400", fieldError != null ? fieldError.getDefaultMessage() : "参数错误");
}
// 这里假设是具体查询数据的业务逻辑...
StudentDto studentDto = StudentDto.builder()
.stuCode(queryStudentDto.getStuCode())
.name("汤姆")
.age(18)
.gender("M")
.score(100)
.build();
return ApiResult.success(studentDto);
}
}
Controller接口中,和普通JSON接口的唯一区别就是@PostMapping
注解中使用了consumes
和produces
这两个属性,它们用于指定该接口接收哪种内容协商类型数据并以哪种方式响应,application/encrypted
就是我们自定义的内容协商类型,它能够关联到我们自定义的消息转换器。
在前端我们需要引入CryptoJS
这个开源库,你也可以尝试使用其它加密库。
Github地址:https://github.com/brix/crypto-js
下面例子代码中,我们在前端发起Fetch请求。代码中添加的Content-Type
和Accept
请求头是关键,服务端通过这些请求头指定的内容协商类型调用消息转换器。后面代码中,在请求里附加了加密的数据,而响应则被解密函数解密。
此外,我们在服务端使用的密钥是abc123
,它在零填充为16字节长度并转为Base64编码后就是YWJjMTIzAAAAAAAAAAAAAA==
,这个CryptoJS
加密库使用Base64密钥比较方便,因此我们将其转换为了这种形式。
window.onload = function () {
var key = 'YWJjMTIzAAAAAAAAAAAAAA==';
/**
* 加密方法
* @param input 明文
* @returns {string} 密文Base64字符串
*/
function e(input) {
var result = CryptoJS.AES.encrypt(input, CryptoJS.enc.Base64.parse(key), {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
return result.toString();
}
/**
* 解密方法
* @param input 密文Base64字符串
* @returns {string} 明文
*/
function d(input) {
var result = CryptoJS.AES.decrypt({
ciphertext: CryptoJS.enc.Base64.parse(input)
}, CryptoJS.enc.Base64.parse(key), {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
});
return result.toString(CryptoJS.enc.Utf8);
}
// 发送POST请求,测试加密和解密
fetch("/api/v1/student/queryStudentByStuCode", {
method: "post",
headers: {
'Content-Type': 'application/encrypted',
'Accept': 'application/encrypted',
},
body: e(JSON.stringify({stuCode: "STU0001"}))
})
.then(function (response) {
return response.text()
})
.then(function (text) {
const decryptedResponse = d(text);
console.log("Decrypted Response:", decryptedResponse);
})
.catch(function (error) {
console.error("Error:", error);
});
};
打开页面,我们便可以查看控制台,观察结果了。
前面的JavaScript代码中,我们的对称密钥是直接写在代码里的,因此我们的源码最好使用混淆器对代码进行混淆,例如obfuscator,这样可以起到一定程度上的反调试作用。
总而言之,所谓“前后端通信加密”是一个广泛存在的误解造成的一种搞笑型安全要求,在理论安全性上它没有任何意义,我们尽量采用简单但符合企业要求的加密算法实现即可。