Files
senmesh-zc/src/main/java/com/senmesh/sfile/client/MeshSafeClient.java

281 lines
10 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.senmesh.sfile.client;
import cn.hutool.core.io.IoUtil;
import com.senmesh.common.exception.BusinessException;
import com.senmesh.common.util.JsonUtils;
import com.senmesh.sfile.config.MeshSafeProperties;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/**
* MeshSafe 文件安全检测客户端
* <p>
* 对接 MeshSafe /api/v1 接口,认证规范见 MeshSafe《对外接口文档》:
* 请求需携带 HMAC-SHA256 签名(密钥经 HKDF-SHA256 派生),响应需验签,
* 验签失败的检测结论一律拒绝使用。
*/
@Slf4j
@Component
public class MeshSafeClient {
private static final String PATH_SCANS = "/api/v1/scans";
private static final String INFO_REQUEST_SIGNING = "meshsafe/api/v1/request-signing";
private static final String INFO_RESPONSE_SIGNING = "meshsafe/api/v1/response-signing";
private static final String HEADER_CLIENT_ID = "X-MS-Client-Id";
private static final String HEADER_TIMESTAMP = "X-MS-Timestamp";
private static final String HEADER_NONCE = "X-MS-Nonce";
private static final String HEADER_BODY_SHA256 = "X-MS-Body-SHA256";
private static final String HEADER_SIGNATURE = "X-MS-Signature";
private static final String HEADER_RESPONSE_TIMESTAMP = "X-MS-Response-Timestamp";
private static final String HEADER_RESPONSE_SIGNATURE = "X-MS-Response-Signature";
private static final String MULTIPART_BOUNDARY_PREFIX = "----MeshSafeBoundary";
@Autowired
private MeshSafeProperties meshSafeProperties;
/**
* 提交文件检测(同步模式直接返回结论,异步模式返回受理结果需轮询)
*
* @param fileName 文件名
* @param fileBytes 文件字节
* @return 检测结果
*/
public MeshSafeScanResult scan(String fileName, byte[] fileBytes) {
String boundary = MULTIPART_BOUNDARY_PREFIX + UUID.randomUUID().toString().replace("-", "");
byte[] body = buildMultipartBody(boundary, fileName, fileBytes);
String contentType = "multipart/form-data; boundary=" + boundary;
Map<String, String> headers = signHeaders("POST", PATH_SCANS, body);
headers.put("Content-Type", contentType);
String responseBody = execute("POST", PATH_SCANS, headers, body);
return parseScanResult(responseBody);
}
/**
* 查询检测任务结果
*
* @param taskId MeshSafe 任务ID
* @return 检测结果
*/
public MeshSafeScanResult getScan(String taskId) {
String path = PATH_SCANS + "/" + taskId;
Map<String, String> headers = signHeaders("GET", path, new byte[0]);
String responseBody = execute("GET", path, headers, null);
return parseScanResult(responseBody);
}
/**
* 回写人工复核结论到 MeshSafe
*
* @param taskId MeshSafe 任务ID
* @param decision 复核结论:pass / reject
* @param comment 复核说明
*/
public void review(String taskId, String decision, String comment) {
String path = PATH_SCANS + "/" + taskId + "/review";
Map<String, Object> bodyMap = new LinkedHashMap<>();
bodyMap.put("decision", decision);
bodyMap.put("comment", comment);
byte[] body = JsonUtils.toJsonString(bodyMap).getBytes(StandardCharsets.UTF_8);
Map<String, String> headers = signHeaders("POST", path, body);
headers.put("Content-Type", "application/json");
execute("POST", path, headers, body);
}
/**
* 发送请求并验签
* 使用JDK原生HttpURLConnection保证multipart字节与签名计算完全一致
* (hutool检测到multipart Content-Type时会重建表单体导致body被覆盖)
*/
private String execute(String method, String path, Map<String, String> headers, byte[] body) {
String url = StringUtils.removeEnd(meshSafeProperties.getBaseUrl(), "/") + path;
byte[] bodyBytes = body == null ? new byte[0] : body;
try {
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod(method);
conn.setConnectTimeout(meshSafeProperties.getTimeoutSeconds() * 1000);
conn.setReadTimeout(meshSafeProperties.getTimeoutSeconds() * 1000);
conn.setDoOutput(true);
for (Map.Entry<String, String> entry : headers.entrySet()) {
conn.setRequestProperty(entry.getKey(), entry.getValue());
}
try (OutputStream os = conn.getOutputStream()) {
os.write(bodyBytes);
}
int status = conn.getResponseCode();
byte[] responseBytes;
try (InputStream is = status >= 400 ? conn.getErrorStream() : conn.getInputStream()) {
responseBytes = IoUtil.readBytes(is);
}
String responseTimestamp = conn.getHeaderField(HEADER_RESPONSE_TIMESTAMP);
String responseSignature = conn.getHeaderField(HEADER_RESPONSE_SIGNATURE);
verifyResponseSignature(responseTimestamp, responseSignature, responseBytes);
String responseBody = new String(responseBytes, StandardCharsets.UTF_8);
if (status < 200 || status >= 300) {
log.error("MeshSafe请求失败, method: {}, path: {}, status: {}, response: {}",
method, path, status, responseBody);
throw new BusinessException("MeshSafe服务返回异常: HTTP " + status);
}
return responseBody;
} catch (BusinessException e) {
throw e;
} catch (Exception e) {
log.error("MeshSafe请求异常, method: {}, path: {}", method, path, e);
throw new BusinessException("MeshSafe服务请求失败: " + e.getMessage(), e);
}
}
/**
* 校验响应签名,验签失败拒绝使用该结论
*/
private void verifyResponseSignature(String timestamp, String signature, byte[] responseBytes) {
if (StringUtils.isBlank(timestamp) || StringUtils.isBlank(signature)) {
throw new BusinessException("MeshSafe响应缺少签名头,拒绝使用该检测结论");
}
byte[] responseKey = deriveKey(INFO_RESPONSE_SIGNING);
String payload = DigestUtils.sha256Hex(responseBytes) + timestamp;
String expected = hmacHex(responseKey, payload);
if (!expected.equalsIgnoreCase(signature)) {
throw new BusinessException("MeshSafe响应签名校验失败,拒绝使用该检测结论");
}
}
private MeshSafeScanResult parseScanResult(String responseBody) {
MeshSafeScanResult result = JsonUtils.toObject(responseBody, MeshSafeScanResult.class);
if (result == null || StringUtils.isBlank(result.getTaskId())) {
throw new BusinessException("MeshSafe响应解析失败: " + responseBody);
}
result.setRawJson(responseBody);
return result;
}
/**
* 构造请求签名头
* 待签名串: METHOD \n PATH \n CANONICAL_QUERY \n BODY_SHA256 \n TIMESTAMP \n NONCE \n CLIENT_ID
*/
private Map<String, String> signHeaders(String method, String path, byte[] body) {
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
String nonce = UUID.randomUUID().toString().replace("-", "");
String bodySha256 = DigestUtils.sha256Hex(body);
String payload = String.join("\n",
method.toUpperCase(), path, "", bodySha256, timestamp, nonce,
meshSafeProperties.getClientId());
String signature = hmacHex(deriveKey(INFO_REQUEST_SIGNING), payload);
Map<String, String> headers = new LinkedHashMap<>();
headers.put(HEADER_CLIENT_ID, meshSafeProperties.getClientId());
headers.put(HEADER_TIMESTAMP, timestamp);
headers.put(HEADER_NONCE, nonce);
headers.put(HEADER_BODY_SHA256, bodySha256);
headers.put(HEADER_SIGNATURE, signature);
return headers;
}
/**
* HKDF-SHA256 密钥派生(RFC 5869, extract + expand 两步)
*/
private byte[] deriveKey(String info) {
try {
byte[] ikm = meshSafeProperties.getSecret().getBytes(StandardCharsets.UTF_8);
byte[] salt = meshSafeProperties.getClientId().getBytes(StandardCharsets.UTF_8);
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt, "HmacSHA256"));
byte[] prk = mac.doFinal(ikm);
byte[] okm = new byte[32];
byte[] block = new byte[0];
int offset = 0;
byte counter = 1;
while (offset < okm.length) {
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
mac.update(block);
mac.update(info.getBytes(StandardCharsets.UTF_8));
mac.update(new byte[]{counter});
block = mac.doFinal();
int n = Math.min(block.length, okm.length - offset);
System.arraycopy(block, 0, okm, offset, n);
offset += n;
counter++;
}
return okm;
} catch (Exception e) {
throw new BusinessException("MeshSafe签名密钥派生失败", e);
}
}
private String hmacHex(byte[] key, String message) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return hex(mac.doFinal(message.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new BusinessException("MeshSafe签名计算失败", e);
}
}
private String hex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
/**
* 手工构造 multipart 请求体(签名需基于最终字节计算,不能用HTTP库自动构造)
*/
private byte[] buildMultipartBody(String boundary, String fileName, byte[] fileBytes) {
try {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
String channel = StringUtils.defaultIfBlank(meshSafeProperties.getChannel(), "default");
bos.write(("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"channel\"\r\n\r\n"
+ channel + "\r\n").getBytes(StandardCharsets.UTF_8));
String safeName = fileName.replace("\"", "");
bos.write(("--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\"" + safeName + "\"\r\n"
+ "Content-Type: application/octet-stream\r\n\r\n").getBytes(StandardCharsets.UTF_8));
bos.write(fileBytes);
bos.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
return bos.toByteArray();
} catch (Exception e) {
throw new BusinessException("构造MeshSafe检测请求失败", e);
}
}
}