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 文件安全检测客户端 *

* 对接 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 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 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 bodyMap = new LinkedHashMap<>(); bodyMap.put("decision", decision); bodyMap.put("comment", comment); byte[] body = JsonUtils.toJsonString(bodyMap).getBytes(StandardCharsets.UTF_8); Map 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 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 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 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 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); } } }