281 lines
10 KiB
Java
281 lines
10 KiB
Java
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|