feat: 对接MeshSafe文件安全检测(上传/解包自动送检+轮询回写,不通过禁止分享传输,文件夹确认后仅分享通过内容,人工复核/重新检测,meshsafe.enabled总开关)

This commit is contained in:
2026-11-01 20:29:31 +08:00
parent c5a262b3dd
commit 4c7dd207d0
30 changed files with 2000 additions and 6 deletions

View File

@@ -0,0 +1,280 @@
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);
}
}
}