fix(channel): 传输加密拦截器区分明文错误响应并对齐 dev 通道密钥

- 拦截器 decryptResponse 按状态码分流: 2xx 无加密头视为协议违规(保留安全约束); 非2xx 明文错误响应解析 body 透传真实错误详情, 避免子应用入站解密失败等错误被「响应未携带加密头」掩盖

- 新增 i18n key channel.error.transportEncrypt.plainErrorResponse (10 语种同步)

- 新增 ChannelTransportEncryptInterceptorTest 覆盖 6 场景

- dev 环境 channel.one/two 传输密钥对齐对应子应用(原复用数据加密密钥且与子应用不匹配, 导致调用 Go/Java 子应用均报错)
This commit is contained in:
daxpay
2026-07-30 12:22:51 +08:00
parent 54b2fa4850
commit 9f985e9ed0
14 changed files with 285 additions and 18 deletions

View File

@@ -17,6 +17,7 @@
"ciphertextInvalid": "Channel transport ciphertext length is invalid",
"decryptFailed": "Channel transport decrypt failed",
"responseHeaderMissing": "Channel response missing transport encrypt header {0}",
"requestHeaderMissing": "Channel transport encrypt: request missing encrypt header"
"requestHeaderMissing": "Channel transport encrypt: request missing encrypt header",
"plainErrorResponse": "Channel sub-app returned plaintext error response [{0}]: {1}"
}
}

View File

@@ -17,6 +17,7 @@
"ciphertextInvalid": "Panjang ciphertext transport saluran tidak valid",
"decryptFailed": "Dekripsi transport saluran gagal",
"responseHeaderMissing": "Respons saluran tidak memiliki header enkripsi transport {0}",
"requestHeaderMissing": "Enkripsi transport saluran: permintaan tanpa header enkripsi"
"requestHeaderMissing": "Enkripsi transport saluran: permintaan tanpa header enkripsi",
"plainErrorResponse": "Sub-aplikasi saluran mengembalikan respons kesalahan teks polos [{0}]: {1}"
}
}

View File

@@ -17,6 +17,7 @@
"ciphertextInvalid": "チャネル転送の暗号文の長さが不正です",
"decryptFailed": "チャネル転送の復号に失敗しました",
"responseHeaderMissing": "チャネル応答に転送暗号化ヘッダー {0} がありません",
"requestHeaderMissing": "チャネル転送暗号化: リクエストに暗号化ヘッダーがありません"
"requestHeaderMissing": "チャネル転送暗号化: リクエストに暗号化ヘッダーがありません",
"plainErrorResponse": "チャネル子アプリから平文エラーレスポンス[{0}]が返されました: {1}"
}
}

View File

@@ -17,6 +17,7 @@
"ciphertextInvalid": "채널 전송 암호문 길이가 잘못되었습니다",
"decryptFailed": "채널 전송 복호화에 실패했습니다",
"responseHeaderMissing": "채널 응답에 전송 암호화 헤더 {0}가 없습니다",
"requestHeaderMissing": "채널 전송 암호화: 요청에 암호화 헤더가 없습니다"
"requestHeaderMissing": "채널 전송 암호화: 요청에 암호화 헤더가 없습니다",
"plainErrorResponse": "채널 자식 앱이 평문 오류 응답[{0}]을 반환했습니다: {1}"
}
}

View File

@@ -17,6 +17,7 @@
"ciphertextInvalid": "Panjang ciphertext pengangkutan saluran tidak sah",
"decryptFailed": "Penyahsulitan pengangkutan saluran gagal",
"responseHeaderMissing": "Respons saluran tiada pengepala penyulitan pengangkutan {0}",
"requestHeaderMissing": "Penyulitan pengangkutan saluran: permintaan tiada pengepala penyulitan"
"requestHeaderMissing": "Penyulitan pengangkutan saluran: permintaan tiada pengepala penyulitan",
"plainErrorResponse": "Sub-aplikasi saluran memulangkan respons ralat teks biasa [{0}]: {1}"
}
}

View File

@@ -17,6 +17,7 @@
"ciphertextInvalid": "ความยาวciphertext การส่งช่องทางไม่ถูกต้อง",
"decryptFailed": "การถอดรหัสการส่งช่องทางล้มเหลว",
"responseHeaderMissing": "การตอบกลับช่องทางไม่มีส่วนหัวเข้ารหัสการส่ง {0}",
"requestHeaderMissing": "การเข้ารหัสการส่งช่องทาง: คำขอไม่มีส่วนหัวเข้ารหัส"
"requestHeaderMissing": "การเข้ารหัสการส่งช่องทาง: คำขอไม่มีส่วนหัวเข้ารหัส",
"plainErrorResponse": "แอปย่อยของช่องทางส่งกลับการตอบกลับข้อผิดพลาดแบบข้อความธรรมดา [{0}]: {1}"
}
}

View File

@@ -17,6 +17,7 @@
"ciphertextInvalid": "Độ dài bản mã truyền tải kênh không hợp lệ",
"decryptFailed": "Giải mã truyền tải kênh thất bại",
"responseHeaderMissing": "Phản hồi kênh thiếu header mã hóa truyền tải {0}",
"requestHeaderMissing": "Mã hóa truyền tải kênh: yêu cầu thiếu header mã hóa"
"requestHeaderMissing": "Mã hóa truyền tải kênh: yêu cầu thiếu header mã hóa",
"plainErrorResponse": "Ứng dụng con kênh trả về phản hồi lỗi văn bản thuần [{0}]: {1}"
}
}

View File

@@ -17,6 +17,7 @@
"ciphertextInvalid": "通道传输密文长度非法",
"decryptFailed": "通道传输解密失败",
"responseHeaderMissing": "通道子应用响应未携带传输加密头 {0}",
"requestHeaderMissing": "通道传输加密:请求未携带加密头"
"requestHeaderMissing": "通道传输加密:请求未携带加密头",
"plainErrorResponse": "通道子应用返回明文错误响应[{0}]: {1}"
}
}

View File

@@ -17,6 +17,7 @@
"ciphertextInvalid": "通道傳輸密文長度非法",
"decryptFailed": "通道傳輸解密失敗",
"responseHeaderMissing": "通道子應用回應未攜帶傳輸加密頭 {0}",
"requestHeaderMissing": "通道傳輸加密:請求未攜帶加密頭"
"requestHeaderMissing": "通道傳輸加密:請求未攜帶加密頭",
"plainErrorResponse": "通道子應用傳回明文錯誤回應[{0}]: {1}"
}
}

View File

@@ -17,6 +17,7 @@
"ciphertextInvalid": "通道傳輸密文長度非法",
"decryptFailed": "通道傳輸解密失敗",
"responseHeaderMissing": "通道子應用回應未攜帶傳輸加密頭 {0}",
"requestHeaderMissing": "通道傳輸加密:請求未攜帶加密頭"
"requestHeaderMissing": "通道傳輸加密:請求未攜帶加密頭",
"plainErrorResponse": "通道子應用回傳明文錯誤回應[{0}]: {1}"
}
}

View File

@@ -46,5 +46,10 @@
<artifactId>common-config</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -13,6 +13,9 @@ import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.util.StreamUtils;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -37,9 +40,19 @@ public class ChannelTransportEncryptInterceptor implements ClientHttpRequestInte
public static final String MSG_RESPONSE_HEADER_MISSING =
"channel.error.transportEncrypt.responseHeaderMissing";
/// 通道子应用返回明文错误响应(子应用主动返回的未加密错误,如入站解密失败 400
public static final String MSG_PLAIN_ERROR_RESPONSE =
"channel.error.transportEncrypt.plainErrorResponse";
private static final MediaType TEXT_PLAIN_UTF8 =
new MediaType(MediaType.TEXT_PLAIN, StandardCharsets.UTF_8);
/// 明文错误 body 片段最大长度,防止异常日志爆炸
private static final int SNIPPET_MAX_LENGTH = 200;
/// 解析子应用明文错误响应的独立 ObjectMapper拦截器为手动 new非 Spring Bean无法注入
private static final ObjectMapper PLAIN_ERROR_MAPPER = JsonMapper.builder().build();
private final ChannelAesGcmEncryptor encryptor;
@Override
@@ -58,7 +71,7 @@ public class ChannelTransportEncryptInterceptor implements ClientHttpRequestInte
return decryptResponse(response);
}
/// 解密响应体;无 body 原样返回;有 body 但无加密头视为协议错误
/// 解密响应体;无 body 原样返回;有加密头则解密;无加密头按状态码区分协议违规与明文错误响应
private ClientHttpResponse decryptResponse(ClientHttpResponse response) throws IOException {
byte[] responseBytes = StreamUtils.copyToByteArray(response.getBody());
if (responseBytes.length == 0) {
@@ -66,16 +79,48 @@ public class ChannelTransportEncryptInterceptor implements ClientHttpRequestInte
}
String encryptedFlag = response.getHeaders().getFirst(HEADER_X_DAX_PAYLOAD_ENCRYPTED);
if (!"true".equalsIgnoreCase(encryptedFlag)) {
response.close();
if ("true".equalsIgnoreCase(encryptedFlag)) {
// 正常加密响应:解密后交给业务层
String plaintext = encryptor.decrypt(new String(responseBytes, StandardCharsets.UTF_8));
byte[] plainBytes = plaintext.getBytes(StandardCharsets.UTF_8);
return new DecryptedClientHttpResponse(response, plainBytes, true);
}
// 无加密头:子应用未按加密协议响应。区分「成功响应未加密」与「明文错误响应」
// 状态码须在 close 前获取,避免部分实现 close 后状态丢失
HttpStatusCode statusCode = response.getStatusCode();
response.close();
if (statusCode.is2xxSuccessful()) {
// 2xx 成功响应却未加密:真正的协议违规(子应用未加密或被劫持),按安全约束报错
// 通道子应用响应未携带传输加密头
throw new BizInfoException(
CommonErrorCode.SYSTEM_ERROR, MSG_RESPONSE_HEADER_MISSING, HEADER_X_DAX_PAYLOAD_ENCRYPTED);
}
// 非 2xx 明文错误响应:子应用入站解密失败/加密失败等主动返回的明文错误(如 400 解密失败)
// 透传子应用返回的真实错误详情,避免被「响应未携带加密头」掩盖
// 通道子应用返回明文错误响应
String detail = extractPlainErrorDetail(responseBytes);
throw new BizInfoException(
CommonErrorCode.SYSTEM_ERROR, MSG_PLAIN_ERROR_RESPONSE, statusCode.value(), detail);
}
String plaintext = encryptor.decrypt(new String(responseBytes, StandardCharsets.UTF_8));
byte[] plainBytes = plaintext.getBytes(StandardCharsets.UTF_8);
return new DecryptedClientHttpResponse(response, plainBytes, true);
/// 从子应用明文错误响应体提取错误详情
/// 优先解析 JSON 的 msg 字段(子应用返回 DaxResult 结构 {"code":400,"msg":"..."}),解析失败回退原始 body 片段
private static String extractPlainErrorDetail(byte[] responseBytes) {
String body = new String(responseBytes, StandardCharsets.UTF_8);
try {
JsonNode node = PLAIN_ERROR_MAPPER.readTree(body);
String msg = node.path("msg").asText("");
if (!msg.isEmpty()) {
return msg;
}
} catch (Exception ignored) {
// 解析失败回退到原始 body 片段
}
// 截断防止异常日志爆炸
return body.length() > SNIPPET_MAX_LENGTH
? body.substring(0, SNIPPET_MAX_LENGTH) + "..."
: body;
}
/// 包装已解密的响应,供 RestClient/HttpExchange 按 JSON 解析

View File

@@ -0,0 +1,205 @@
package cn.daxpay.open.platform.common.spring.channel;
import cn.daxpay.open.platform.common.config.encrypt.ChannelAesGcmEncryptor;
import cn.daxpay.open.platform.core.exception.BizInfoException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpRequest;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpResponse;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/// # 通道传输报文加解密拦截器测试
///
/// 覆盖 [ChannelTransportEncryptInterceptor#decryptResponse] 的四种分支:
/// 1. 正常加密响应2xx + 加密头)应解密为明文 JSON
/// 2. 成功响应未加密2xx + 无加密头)应抛「响应未携带传输加密头」(协议违规)
/// 3. 子应用明文错误响应(非 2xx + 无加密头)应抛「明文错误响应」并透传真实错误详情
/// 4. 空 body 响应原样放行
class ChannelTransportEncryptInterceptorTest {
private ChannelAesGcmEncryptor encryptor;
private ChannelTransportEncryptInterceptor interceptor;
@BeforeEach
void initInterceptor() {
encryptor = new ChannelAesGcmEncryptor(generateKey(32));
interceptor = new ChannelTransportEncryptInterceptor(encryptor);
}
@Test
@DisplayName("正常加密响应200 + 加密头)应解密为明文 JSON")
void shouldDecryptEncryptedResponse() throws IOException {
// 模拟子应用返回的加密响应
String plaintext = "{\"code\":0,\"data\":\"ok\"}";
String ciphertext = encryptor.encrypt(plaintext);
ClientHttpResponse response = mockResponse(200, ciphertext, true);
ClientHttpResponse result = interceptor.intercept(mockRequest(), new byte[0], executionReturning(response));
// 出站请求 body 为空时不会被加密;响应应被解密为明文 JSON
byte[] body = result.getBody().readAllBytes();
assertEquals(plaintext, new String(body, StandardCharsets.UTF_8));
}
@Test
@DisplayName("成功响应未携带加密头应抛协议违规错误")
void shouldThrowWhenSuccessResponseMissingEncryptedHeader() {
// 2xx 成功响应却未加密,属于真正的协议违规
ClientHttpResponse response = mockResponse(200, "{\"code\":0,\"data\":\"ok\"}", false);
BizInfoException ex = assertThrows(BizInfoException.class,
() -> interceptor.intercept(mockRequest(), new byte[0], executionReturning(response)));
assertEquals(ChannelTransportEncryptInterceptor.MSG_RESPONSE_HEADER_MISSING, ex.getMessageKey());
}
@Test
@DisplayName("子应用明文错误响应400 解密失败)应透传真实错误详情")
void shouldUnwrapPlainErrorResponseDetail() {
// 模拟子应用入站解密失败返回的 400 明文 JSON
String plainErrorBody = "{\"code\":400,\"msg\":\"通道传输解密失败\"}";
ClientHttpResponse response = mockResponse(400, plainErrorBody, false);
BizInfoException ex = assertThrows(BizInfoException.class,
() -> interceptor.intercept(mockRequest(), new byte[0], executionReturning(response)));
// 应抛「明文错误响应」而非「响应未携带加密头」,并携带状态码与真实错误 msg
assertEquals(ChannelTransportEncryptInterceptor.MSG_PLAIN_ERROR_RESPONSE, ex.getMessageKey());
Object[] args = ex.getArgs();
assertEquals(400, args[0]);
assertEquals("通道传输解密失败", args[1]);
}
@Test
@DisplayName("明文错误 body 非 JSON 时应回退为原始片段")
void shouldFallbackToRawSnippetWhenBodyNotJson() {
// 子应用返回非 JSON 明文(如网关/代理介入)
ClientHttpResponse response = mockResponse(502, "Bad Gateway", false);
BizInfoException ex = assertThrows(BizInfoException.class,
() -> interceptor.intercept(mockRequest(), new byte[0], executionReturning(response)));
assertEquals(ChannelTransportEncryptInterceptor.MSG_PLAIN_ERROR_RESPONSE, ex.getMessageKey());
assertEquals(502, ex.getArgs()[0]);
// 回退为原始 body 片段
assertEquals("Bad Gateway", ex.getArgs()[1]);
}
@Test
@DisplayName("空 body 响应原样放行")
void shouldPassThroughEmptyBody() throws IOException {
ClientHttpResponse response = mockResponse(200, "", true);
ClientHttpResponse result = interceptor.intercept(mockRequest(), new byte[0], executionReturning(response));
// 空 body 不解密,原样返回空字节数组
assertArrayEquals(new byte[0], result.getBody().readAllBytes());
}
@Test
@DisplayName("出站非空请求 body 应被加密并携带加密头")
void shouldEncryptNonEmptyRequestBody() throws IOException {
String requestBody = "{\"channel\":\"alipay\"}";
byte[] body = requestBody.getBytes(StandardCharsets.UTF_8);
// 加密响应,确保入站不抛错
String ciphertext = encryptor.encrypt("{\"code\":0}");
ClientHttpResponse response = mockResponse(200, ciphertext, true);
// 捕获实际发给 execution 的请求 body
HttpRequest request = mockRequest();
interceptor.intercept(request, body, executionReturning(response));
// 请求应被加密并设置加密头
assertEquals("true", request.getHeaders().getFirst(ChannelTransportEncryptInterceptor.HEADER_X_DAX_PAYLOAD_ENCRYPTED));
}
/// 构造返回固定响应的 execution
private static ClientHttpRequestExecution executionReturning(ClientHttpResponse response) {
return (request, body) -> response;
}
/// 构造最简 HttpRequest带可写 headers
private static HttpRequest mockRequest() {
HttpHeaders headers = new HttpHeaders();
return new HttpRequest() {
@Override
public HttpMethod getMethod() {
return HttpMethod.POST;
}
@Override
public URI getURI() {
return URI.create("http://localhost/channel/test");
}
@Override
public HttpHeaders getHeaders() {
return headers;
}
@Override
public Map<String, Object> getAttributes() {
return Map.of();
}
};
}
/// 构造 mock 响应
private static ClientHttpResponse mockResponse(int status, String body, boolean withEncryptedHeader) {
HttpHeaders headers = new HttpHeaders();
if (withEncryptedHeader) {
headers.add(ChannelTransportEncryptInterceptor.HEADER_X_DAX_PAYLOAD_ENCRYPTED, "true");
}
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
return new ClientHttpResponse() {
@Override
public HttpStatusCode getStatusCode() {
return HttpStatusCode.valueOf(status);
}
@Override
public String getStatusText() {
return "";
}
@Override
public void close() {
// no-op
}
@Override
public InputStream getBody() {
return new ByteArrayInputStream(bytes);
}
@Override
public HttpHeaders getHeaders() {
return headers;
}
};
}
/// 生成指定长度的随机密钥字符串
private static String generateKey(int length) {
byte[] keyBytes = new byte[length];
new SecureRandom().nextBytes(keyBytes);
return Base64.getEncoder().encodeToString(keyBytes).substring(0, length);
}
}

View File

@@ -175,13 +175,15 @@ daxpay:
one:
base-url: http://127.0.0.1:20100
# 通道传输 AES 密钥(强制常开,恰好 32 字符;仅本地占位,勿用于生产)
# 须与子应用一致: dax-pay-channel-one application.yml / dax-pay-channel-one-go config.yaml 的 channel.transport-encrypt.key
transport-encrypt:
key: z0Vd8jDKB80pA6OOptGLO+qDVvWboEko
key: 0123456789abcdef0123456789abcdef
# 子应用2: 其他支付通道
two:
base-url: http://127.0.0.1:20200
# 须与子应用一致: dax-pay-channel-two application.yml 的 channel.transport-encrypt.key
transport-encrypt:
key: z0Vd8jDKB80pA6OOptGLO+qDVvWboEko
key: fedcba9876543210fedcba9876543210
# 子应用3: 其他通道(未来扩展, 暂未启用)
# three:
# base-url: http://127.0.0.1:20300