diff --git a/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java b/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java
new file mode 100644
index 0000000..d6c8360
--- /dev/null
+++ b/src/main/java/com/yunhai/platform/gateway/filter/SignVerifyFilter.java
@@ -0,0 +1,159 @@
+package com.yunhai.platform.gateway.filter;
+
+import com.yunhai.platform.gateway.auth.GatewayKeyService;
+import com.yunhai.platform.gateway.envelope.EnvelopeWriter;
+import com.yunhai.platform.gateway.route.GatewayProperties;
+import com.yunhai.platform.utils.crypto.SignUtil;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * v1 验签过滤器(T1.2,共享向量):canonical 与 starter
+ * {@code AppAuthFilter.buildSignedTarget + SignCanonical} 逐字节一致——
+ * {@code METHOD\n剥掉 target 首段后的 path[?解码排序query]\ntimestamp},HmacSHA256-Base64(F1),
+ * 与客户端"剥 basePath 后签名"严格对齐;比对复用 starter 侧同一 {@link SignUtil}(不重写算法),
+ * 时间窗 ±5min(F2,不得更宽)。
+ *
+ * 头缺失 / appKey 未知或禁用({@link GatewayKeyService} null = fail-closed)/ 验签失败 /
+ * 时间窗超出 → 判定 FAIL:observe=true 只记台账(verdict=FAIL_OBSERVED)放行(R4),
+ * observe=false 裸 401(F5 契约)。判定结果经 request attribute 传递
+ * ({@link #ATTR_VERDICT} / {@link #ATTR_AUTH_APPKEY}),供 T1.4 身份注入与 T1.5 台账消费。
+ *
+ * 链序第 3 环(T1.6 定型):Sanitize(1) → 路由判定(2) → 本过滤器(3) → 身份注入(4) → 转发(5)。
+ */
+@Component
+@Order(3)
+public class SignVerifyFilter implements Filter {
+
+ /** 判定结果 attribute(T1.5/T1.6 台账 verdict) */
+ public static final String ATTR_VERDICT = "yh.gateway.verdict";
+ /** 验签通过的 appKey(T1.4 身份注入唯一合法来源) */
+ public static final String ATTR_AUTH_APPKEY = "yh.gateway.auth.appKey";
+
+ public static final String VERDICT_PASS = "PASS";
+ public static final String VERDICT_FAIL = "FAIL";
+ public static final String VERDICT_FAIL_OBSERVED = "FAIL_OBSERVED";
+ public static final String VERDICT_ANON = "ANON";
+
+ @Autowired
+ private GatewayKeyService keyService;
+
+ @Autowired
+ private GatewayProperties properties;
+
+ /** 测试/显式装配入口(同 TargetRouter.setProperties 惯例) */
+ void setKeyService(GatewayKeyService keyService) {
+ this.keyService = keyService;
+ }
+
+ void setProperties(GatewayProperties properties) {
+ this.properties = properties;
+ }
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+ if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) {
+ chain.doFilter(request, response);
+ return;
+ }
+ HttpServletRequest req = (HttpServletRequest) request;
+ HttpServletResponse res = (HttpServletResponse) response;
+
+ boolean pass = false;
+ String appKey = req.getHeader("X-App-Key");
+ String timestamp = req.getHeader("X-Timestamp");
+ String signature = req.getHeader("X-Signature");
+ String strippedPath = (String) req.getAttribute(SanitizeFilter.ATTR_STRIPPED_PATH);
+ if (!isBlank(appKey) && !isBlank(timestamp) && !isBlank(signature) && strippedPath != null) {
+ String secret = keyService.getSecret(appKey); // null = 未知/禁用/异常,fail-closed
+ if (secret != null) {
+ String target = buildCanonicalTarget(strippedPath, req.getQueryString());
+ try {
+ // 复用 starter 同一验签实现:签名比对 + 时间窗(默认 ±5min,F2)
+ pass = SignUtil.verifyRequestWithExpire(req.getMethod(), target, timestamp, secret, signature);
+ } catch (RuntimeException e) {
+ pass = false; // 意外异常同样落 FAIL(判定组件绝不 500)
+ }
+ }
+ }
+
+ if (pass) {
+ req.setAttribute(ATTR_VERDICT, VERDICT_PASS);
+ req.setAttribute(ATTR_AUTH_APPKEY, appKey);
+ chain.doFilter(request, response);
+ return;
+ }
+ if (properties.isObserve()) {
+ // R4:P1 只观察——记台账不拦(台账写入由 T1.5/T1.6 消费本 attribute)
+ req.setAttribute(ATTR_VERDICT, VERDICT_FAIL_OBSERVED);
+ chain.doFilter(request, response);
+ } else {
+ req.setAttribute(ATTR_VERDICT, VERDICT_FAIL);
+ EnvelopeWriter.writeBare(res, 401);
+ }
+ }
+
+ /**
+ * canonical 的 target 部分 = path + (有 query 时 {@code ?解码排序query})——
+ * 与 starter {@code SignCanonical.buildTarget(sortRawQuery(...))} 同语义(共享向量钉死)。
+ */
+ static String buildCanonicalTarget(String pathAfterStrip, String rawQuery) {
+ String sorted = sortRawQuery(rawQuery);
+ return sorted.isEmpty() ? pathAfterStrip : pathAfterStrip + "?" + sorted;
+ }
+
+ /** 复刻 starter SignCanonical.sortRawQuery:逐对解码、{@code k=v} 字典序排序、& 连接 */
+ static String sortRawQuery(String rawQuery) {
+ if (rawQuery == null || rawQuery.isEmpty()) {
+ return "";
+ }
+ List pairs = new ArrayList<>();
+ for (String part : rawQuery.split("&")) {
+ if (part.isEmpty()) {
+ continue;
+ }
+ int idx = part.indexOf('=');
+ String k = idx < 0 ? part : part.substring(0, idx);
+ String v = idx < 0 ? "" : part.substring(idx + 1);
+ pairs.add(urlDecode(k) + "=" + urlDecode(v));
+ }
+ Collections.sort(pairs);
+ StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < pairs.size(); i++) {
+ if (i > 0) {
+ sb.append('&');
+ }
+ sb.append(pairs.get(i));
+ }
+ return sb.toString();
+ }
+
+ private static String urlDecode(String s) {
+ try {
+ return URLDecoder.decode(s, StandardCharsets.UTF_8.name());
+ } catch (UnsupportedEncodingException e) {
+ return s;
+ }
+ }
+
+ private static boolean isBlank(String s) {
+ return s == null || s.trim().isEmpty();
+ }
+}
diff --git a/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterTest.java b/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterTest.java
new file mode 100644
index 0000000..7ae15c4
--- /dev/null
+++ b/src/test/java/com/yunhai/platform/gateway/filter/SignVerifyFilterTest.java
@@ -0,0 +1,172 @@
+package com.yunhai.platform.gateway.filter;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yunhai.platform.gateway.auth.GatewayKeyService;
+import com.yunhai.platform.gateway.route.GatewayProperties;
+import com.yunhai.platform.utils.crypto.SignUtil;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockFilterChain;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.io.InputStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * SignVerifyFilter v1 判定用例(T1.2 TDD):canonical 与 starter 逐字节一致
+ * (剥 target 首段后的 path + 解码排序 query + timestamp,{@code shared-sign-vectors/v1.json} 同源对拍);
+ * observe=true 只记不拦(verdict=FAIL_OBSERVED 放行),observe=false 坏签名裸 401;
+ * 时间窗 ±5min(F2,不得更宽)。
+ */
+class SignVerifyFilterTest {
+
+ private static final String SECRET = "sec-alpha-0123456789abcdef";
+
+ private SignVerifyFilter filter(boolean observe) {
+ SignVerifyFilter f = new SignVerifyFilter();
+ GatewayKeyService keyService = mock(GatewayKeyService.class);
+ when(keyService.getSecret("app1")).thenReturn(SECRET);
+ GatewayProperties props = new GatewayProperties();
+ props.setObserve(observe);
+ f.setKeyService(keyService);
+ f.setProperties(props);
+ return f;
+ }
+
+ /** 模拟已过 SanitizeFilter 的网关请求:attribute 契约 + 三签名头 */
+ private MockHttpServletRequest request(String method, String strippedPath, String query,
+ String appKey, String ts, String sign) {
+ MockHttpServletRequest req = new MockHttpServletRequest(method, "/userbase" + strippedPath);
+ if (query != null) {
+ req.setQueryString(query);
+ }
+ req.setAttribute(SanitizeFilter.ATTR_STRIPPED_PATH, strippedPath);
+ if (appKey != null) {
+ req.addHeader("X-App-Key", appKey);
+ }
+ if (ts != null) {
+ req.addHeader("X-Timestamp", ts);
+ }
+ if (sign != null) {
+ req.addHeader("X-Signature", sign);
+ }
+ return req;
+ }
+
+ private MockHttpServletResponse run(SignVerifyFilter f, MockHttpServletRequest req,
+ MockFilterChain chain) throws Exception {
+ MockHttpServletResponse res = new MockHttpServletResponse();
+ f.doFilter(req, res, chain);
+ return res;
+ }
+
+ @Test
+ void 合法签名判定PASS且放行() throws Exception {
+ String ts = String.valueOf(System.currentTimeMillis());
+ String sign = SignUtil.signRequest("GET", "/oapi/message/list", ts, SECRET);
+ MockFilterChain chain = new MockFilterChain();
+ MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, sign);
+ MockHttpServletResponse res = run(filter(true), req, chain);
+ assertThat(res.getStatus()).isEqualTo(200);
+ assertThat(chain.getRequest()).as("链条继续").isNotNull();
+ assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_PASS);
+ assertThat(req.getAttribute(SignVerifyFilter.ATTR_AUTH_APPKEY)).as("供 T1.4 身份注入").isEqualTo("app1");
+ }
+
+ @Test
+ void 合法签名含排序query判定PASS() throws Exception {
+ String ts = String.valueOf(System.currentTimeMillis());
+ // 客户端按解码排序后 query 签名(starter sortRawQuery 同语义)
+ String sign = SignUtil.signRequest("GET", "/oapi/user/byId?id=7&name=zhang san", ts, SECRET);
+ MockFilterChain chain = new MockFilterChain();
+ MockHttpServletRequest req = request("GET", "/oapi/user/byId", "name=zhang%20san&id=7", "app1", ts, sign);
+ MockHttpServletResponse res = run(filter(false), req, chain);
+ assertThat(res.getStatus()).as("乱序 query 经网关归一后仍 PASS").isEqualTo(200);
+ assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_PASS);
+ }
+
+ @Test
+ void 坏签名observe下放行且记FAIL() throws Exception {
+ String ts = String.valueOf(System.currentTimeMillis());
+ MockFilterChain chain = new MockFilterChain();
+ MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, "deadbeef");
+ MockHttpServletResponse res = run(filter(true), req, chain);
+ assertThat(res.getStatus()).as("观察模式不拦").isEqualTo(200);
+ assertThat(chain.getRequest()).isNotNull();
+ assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT))
+ .as("台账 verdict=FAIL(观察形态)").isEqualTo(SignVerifyFilter.VERDICT_FAIL_OBSERVED);
+ }
+
+ @Test
+ void 坏签名非observe下裸401() throws Exception {
+ String ts = String.valueOf(System.currentTimeMillis());
+ MockFilterChain chain = new MockFilterChain();
+ MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, "deadbeef");
+ MockHttpServletResponse res = run(filter(false), req, chain);
+ assertThat(res.getStatus()).isEqualTo(401);
+ assertThat(res.getContentAsByteArray()).as("裸码无信封").isEmpty();
+ assertThat(chain.getRequest()).as("链条中断").isNull();
+ assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
+ }
+
+ @Test
+ void 头缺失判定FAIL() throws Exception {
+ MockFilterChain chain = new MockFilterChain();
+ MockHttpServletRequest req = request("GET", "/oapi/message/list", null, null, null, null);
+ MockHttpServletResponse res = run(filter(false), req, chain);
+ assertThat(res.getStatus()).as("三头缺失 = FAIL 裸 401").isEqualTo(401);
+ assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
+ }
+
+ @Test
+ void 未知appKey判定FAIL() throws Exception {
+ String ts = String.valueOf(System.currentTimeMillis());
+ String sign = SignUtil.signRequest("GET", "/oapi/message/list", ts, SECRET);
+ MockFilterChain chain = new MockFilterChain();
+ // appKey=ghost:mock 的 getSecret 返回 null(未知/禁用 fail-closed)
+ MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "ghost", ts, sign);
+ MockHttpServletResponse res = run(filter(false), req, chain);
+ assertThat(res.getStatus()).isEqualTo(401);
+ }
+
+ @Test
+ void 时间窗超出判定FAIL() throws Exception {
+ // ±5min(F2):6min 前签名 + 正确算法也必须 FAIL
+ String ts = String.valueOf(System.currentTimeMillis() - 6 * 60 * 1000L);
+ String sign = SignUtil.signRequest("GET", "/oapi/message/list", ts, SECRET);
+ MockFilterChain chain = new MockFilterChain();
+ MockHttpServletRequest req = request("GET", "/oapi/message/list", null, "app1", ts, sign);
+ MockHttpServletResponse res = run(filter(false), req, chain);
+ assertThat(res.getStatus()).as("时间窗超出 = FAIL 裸 401").isEqualTo(401);
+ assertThat(req.getAttribute(SignVerifyFilter.ATTR_VERDICT)).isEqualTo(SignVerifyFilter.VERDICT_FAIL);
+ }
+
+ @Test
+ void 共享向量与starter逐字节一致() throws Exception {
+ ObjectMapper mapper = new ObjectMapper();
+ JsonNode root;
+ try (InputStream in = getClass().getResourceAsStream("/shared-sign-vectors/v1.json")) {
+ assertThat(in).as("共享向量资源存在").isNotNull();
+ root = mapper.readTree(in);
+ }
+ for (JsonNode v : root.get("vectors")) {
+ String secret = v.get("secret").asText();
+ String method = v.get("method").asText();
+ String path = v.get("pathAfterStrip").asText();
+ String rawQuery = v.get("rawQuery").isNull() ? null : v.get("rawQuery").asText();
+ String ts = v.get("timestamp").asText();
+ String expectedTarget = v.get("canonicalTarget").asText();
+ String expectedSign = v.get("expectedSignature").asText();
+
+ String target = SignVerifyFilter.buildCanonicalTarget(path, rawQuery);
+ assertThat(target).as("canonical target 构造同源(%s %s)", method, rawQuery).isEqualTo(expectedTarget);
+ assertThat(SignUtil.signRequest(method, target, ts, secret))
+ .as("期望签名逐字节一致(%s %s)", method, path)
+ .isEqualTo(expectedSign);
+ }
+ }
+}
diff --git a/src/test/resources/shared-sign-vectors/v1.json b/src/test/resources/shared-sign-vectors/v1.json
new file mode 100644
index 0000000..eef79bd
--- /dev/null
+++ b/src/test/resources/shared-sign-vectors/v1.json
@@ -0,0 +1,52 @@
+{
+ "protocol": "v1",
+ "canonical": "METHOD\\npath[?decoded-sorted-query]\\ntimestamp -> HmacSHA256 -> Base64",
+ "source": "由 yh-app-auth-spring-boot-starter 真实代码(SignCanonical.sortRawQuery/buildTarget,反射调用)+ yh-utils-core SignUtil.signRequest 于 2026-09-21 执行生成(starter jar 1.0.0-SNAPSHOT,与 AppAuthFilter.buildSignedTarget 同一链路);三方对拍基线(§9 A4)",
+ "vectors": [
+ {
+ "secret": "sec-alpha-0123456789abcdef",
+ "method": "GET",
+ "pathAfterStrip": "/oapi/message/list",
+ "rawQuery": null,
+ "timestamp": "1758441600000",
+ "canonicalTarget": "/oapi/message/list",
+ "expectedSignature": "Regw08O602Tp61nhH6oG/5VIvyfmU9ZbWs42P7OHzj4="
+ },
+ {
+ "secret": "sec-alpha-0123456789abcdef",
+ "method": "GET",
+ "pathAfterStrip": "/oapi/user/byId",
+ "rawQuery": "id=7&name=zhang%20san",
+ "timestamp": "1758441600001",
+ "canonicalTarget": "/oapi/user/byId?id=7&name=zhang san",
+ "expectedSignature": "PfmaaIf+D+S4qcbcstvOzvhfDYOR8IQJlqTtX2/LfvM="
+ },
+ {
+ "secret": "sec-bravo-9876543210fedcba",
+ "method": "POST",
+ "pathAfterStrip": "/oapi/msg/sendUserMsg",
+ "rawQuery": "b=2&a=1&a=0",
+ "timestamp": "1758441600002",
+ "canonicalTarget": "/oapi/msg/sendUserMsg?a=0&a=1&b=2",
+ "expectedSignature": "Z8psqh0LER3OKQLzuJM2aqUpjHoZCcg/Ru5N5zT0rc8="
+ },
+ {
+ "secret": "sec-bravo-9876543210fedcba",
+ "method": "DELETE",
+ "pathAfterStrip": "/oapi/group/delete",
+ "rawQuery": "gid=3",
+ "timestamp": "1758441600003",
+ "canonicalTarget": "/oapi/group/delete?gid=3",
+ "expectedSignature": "oGBfhZrmge5w3RSiVOjEvkhcTyz27G8W8yCfcBqTYJ0="
+ },
+ {
+ "secret": "sec-alpha-0123456789abcdef",
+ "method": "GET",
+ "pathAfterStrip": "/oapi/OAStu/list",
+ "rawQuery": "page=1&size=20",
+ "timestamp": "1758441600004",
+ "canonicalTarget": "/oapi/OAStu/list?page=1&size=20",
+ "expectedSignature": "e4063mlXWxa3aB/yyXV7Umzvsn8o0hUIIzxdgNuQ5I0="
+ }
+ ]
+}