Back

给 Claude 中转站验明正身:签名里的模型身份

By ROYWANG 八月 01, 2026 NOTE

用中转站的 Claude,价格是真便宜,渠道也多得眼花缭乱,但有个问题一直绕不开:我花的钱,买到的到底是不是真货?

说实话,光看响应体感上很难分辨。thinking 块也有,signature 也有,service_tier 也对,id 也是 msg_01 开头——看着挺像那么回事。但”像”不等于”是”。这些东西说白了都能伪造,signature 也就是一串 base64,谁都能随便拼一个。

所以我花了一些时间,折腾出了一套验证方法,从解码签名明文开始,到交叉验证签名的真假,再到写个定时任务持续巡检。这里完整记录一下,代码全用 Go 写的,跑在服务器上挂着就行。

签名到底是个啥

Anthropic 的响应里,如果启用了 thinking(就是 extended thinking),返回的 thinking 块会带一个 signature 字段。这个东西本质上是用 Anthropic 的私钥对完整思考内容做的数字签名。

关键的是:这个私钥全世界只有 Anthropic 自己拿着,第三方手里没有,也推导不出来。所以如果一个签名的确是官方私钥签的,那就是真的;如果签不出来,那只能是假的。这就是整套验证方案能成立的底层逻辑。

打个不太严谨但好理解的比方:签名就像官方的防伪钢印。你可以仿制包装盒(伪造响应格式),但你弄不出那个钢印的凹凸纹理(私钥签名),拿去官方一验就露馅。

第一步:解码签名明文,先把最假的筛掉

签名虽然是加密的,但你把它 base64 decode 之后,里面其实藏着一小段明文——官方签名的时候会写死一个模型标识进去,类似这样:

1
2
claude-fable-5
thinking

这段明文不是加密的,就是编码在签名结构里的元数据。也就是说,你不需要任何密钥,光靠 base64 解码就能看到它里面写的是哪个模型。

写个简单的 Go 程序就能提取出来,你获得的是 claude-fable-58,这是正常的,如果你要检测 Opus,记得修改:if strings.Contains(mid, "fable")

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import (
"encoding/base64"
"fmt"
"regexp"
"strings"
)

// modelID 从签名中提取内部的模型标识明文
func modelID(signature string) string {
raw, err := base64.StdEncoding.DecodeString(signature)
if err != nil {
return ""
}
re := regexp.MustCompile(`claude-[a-z0-9.\-]+`)
return re.FindString(string(raw))
}

func main() {
sig := "CAIS2QEKYggPGAIqQJBHPiLZ..." // 替换成你响应里的 signature
mid := modelID(sig)
if mid == "" {
fmt.Println("没找到模型标识,响应可能没有 thinking 块,或者压根就是伪造的")
return
}
fmt.Println("签名里写的模型:", mid)
if strings.Contains(mid, "fable") {
fmt.Println("跟请求的 claude-fable-5 对上了")
} else {
fmt.Println("警告:模型标识不匹配!")
}
}

这个阶段能筛掉什么?筛掉那种连装都懒得装像的:你请求的是 claude-fable-5,结果签名明文里写的是 claude-opus-4-8——都不用往下验了,直接拉黑。这种大概率是下游套了个别的便宜模型在冒充,连签名的模型标识都没改。

当然,如果你请求的模型本身就是小版本号很多的(比如 haiku、sonnet 的不同迭代),明文里显示的可能是更底层的模型代号,不一定跟你请求的模型名严格一致。但至少型号得对得上——你请求的是 fable 系列,签名里却写着 opus,怎么都说不过去。

第二步:交叉验证——让官方来认

第一步能筛掉最粗制滥造的假货,但有个问题:明文本身也能伪造。我拼一个包含 claude-fable-58 字符串的 base64 很难吗?几行代码的事。

所以真正的硬验证是:把这个签名发给官方,看它认不认。前面说了,签名的私钥只有 Anthropic 有。你用私钥签出来的东西,官方验证的时候能过;你伪造的字符串,就算明文写对了,到官方那里一验就报 Invalid signature。

问题来了:怎么发回给官方验证?如果你手上本来就有官方的 Claude API key,那直接用官方 API 验就行。但说实话,大部分人用中转站的原因就是因为没有官方的 key,毕竟 A/ 的德行,都懂的…..

这时候 OpenRouter 就派上用场了。OpenRouter 的 Anthropic 端点后端直接就是官方,而且它支持 provider 参数来强制指定路由到哪个供应商。只要把 provider 设成 {"order": ["anthropic"]},你的请求就一定会走到 Anthropic 官方后端,不会被负载均衡到其他云上的供应商。

关键依据在这里,官方文档白纸黑字写的:

signature values are compatible across platforms (Claude APIs, Amazon Bedrock, and Google Cloud). Values generated on one platform work on another.
—— Thinking encryption 官方文档

翻译一下:签名跨平台兼容,一个平台生成的签名在另一个平台也有效。所以不管你的中转站上游是 Azure、Bedrock 还是 Google Cloud,它的响应签名都能拿到 OpenRouter 去验证。

这个兼容性或许不是巧合,是 Anthropic 故意设计的——签名用的是同一套私钥,验证自然也是同一套逻辑。如果签名有问题,你就会收获:

1
{"error":{"message":"Provider returned error","code":400,"metadata":{"raw":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_011CdaeZ8yEJYQopRE6Nrucu\"}","provider_name":"Azure","is_byok":false,"previous_errors":[{"code":400,"message":"Provider returned error","provider_name":"Anthropic","raw":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_011CdaeYtExbLzgxyzirpHAV\"}"},{"code":400,"message":"Provider returned error","provider_name":"Amazon Bedrock","raw":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_011CdaeYwFHWbEZbg6Y13nbz\"}"},{"code":400,"message":"Provider returned error","provider_name":"Google","raw":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_vrtx_011CdaeYyzjKCiFJ8jmJEjVT\"}"}]}},"user_id":"user_3AJa6bua6Af5ze9Gfl8w46LSeHK"}

验证的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package main

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)

// verifySignature 把签名原样发给 OpenRouter(强制走 Anthropic 后端)验证
func verifySignature(signature string) (int, error) {
payload := map[string]any{
"model": "anthropic/claude-fable-5",
"max_tokens": 64,
"provider": map[string]any{"order": []string{"anthropic"}},
"messages": []map[string]any{
{"role": "user", "content": "2+2等于多少?"},
{"role": "assistant", "content": []map[string]any{
{"type": "thinking", "thinking": "", "signature": signature},
{"type": "text", "text": "4"},
}},
{"role": "user", "content": "现在加3等于多少?"},
},
}
body, err := json.Marshal(payload)
if err != nil {
return 0, err
}
req, err := http.NewRequest("POST", "https://openrouter.ai/api/v1/messages", bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("content-type", "application/json")
req.Header.Set("x-api-key", os.Getenv("OPENROUTER_KEY"))
req.Header.Set("anthropic-version", "2023-06-01")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return resp.StatusCode, nil
}

func main() {
code, err := verifySignature(os.Args[1])
if err != nil {
fmt.Println("请求失败:", err)
return
}
if code == 200 {
fmt.Println("签名有效:这个响应确实是官方 Claude 生成的")
} else {
fmt.Printf("签名无效:HTTP %d,响应大概率是伪造的\n", code)
}
}

判断逻辑非常直白:200 = 签名是真的,官方的私钥签的;400 且 body 里是 Invalid signature = 伪造的。其他错误码(比如 401/429)那是你自己的 key 或者额度问题,不是签名的问题。

第三步:A 模型的签名丢给 B 模型,会怎样?

有人可能会动歪脑筋:你不是要我用 fable-5 的签名来证明我调的是 fable-5 吗?那我拿一个 opus-4-8 的真签名来冒充行不行?反正都是真签名,都是官方签的,你的交叉验证能过就行。

好问题。实测结果:OpenRouter 不会报错,直接 200 通过

这就是官方文档里写的另一个行为:

Thinking blocks are tied to the model that produced them. Other models silently ignore them rather than rejecting the request.

思考块(Thinking blocks)与生成它们的模型绑定。其他模型会默默忽略它们,而不会拒绝请求。

签名验证其实分了两个层面:

  1. 完整性验证:签名本身是不是官方私钥签的、有没有被篡改过?如果签名的内容跟 thinking 原文对不上,或者根本就是个假签名,必报 400 Invalid signature。
  2. 模型绑定:签名是哪个模型签的?如果你把 opus-4-8 的签名塞进 fable-5 的请求,官方不会拒绝你——它只是静默忽略这个旧签名,然后用 fable-5 重新思考一遍,返回一个新的 fable-5 签名。

所以单靠交叉验证(只看 200 还是 400)确实有被绕过的风险:只要能搞到一个任何模型生成的真签名,交叉验证这一步就能过。

但别急,这就是为什么第一步的明文解析不能省。交叉验证过了只能说明”这个签名是官方签的”,而明文解析告诉你”这个签名是哪个模型签的”。

opus-4-8 的签名明文里是 claude-opus-4-8,fable-5 的签名明文里是 claude-fable-58。想冒充 fable-5?那你得有一个 fable-5 生成的签名——但你要是有 fable-5 的真签名,说明你确实调了 fable-5,还有什么好冒充的呢?

这就是”明文筛选 + 交叉验证”组合在一起才完整的原因:

  • 交叉验证回答”签名是不是官方签的”(真伪问题)
  • 明文解析回答”官方签的是哪个模型”(身份问题)

两个都过了,你才能确定:这个响应是官方 Claude 生成的,而且生成它的模型就是你请求的那个。

你今天测了,明天呢?

说实话,这套方法有一个天然的软肋:它只验证了你手动测试的那一条请求

中转站完全可以第一天给你真的(甚至头一个星期都给你真的),等你信任了、充值了、开始大量用了,某一天偷偷把后端切成便宜的假模型。你手动测一次根本发现不了——你要是在它换成假模型的第二天刚好没测,那你就一直在用假货而不自知。

这不是理论上的担忧。中转站的商业模式决定了它天然有动机降低成本——上游 API 的价格是刚性的,但卖给用户的价格是可以自由定价的。如果中间差价不够吃了,切个便宜的”平替”是最直接的省钱方式。

所以对策也很直接:别手动测,写个程序让它跑着

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
package main

import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"regexp"
"strings"
"time"
)

var client = &http.Client{Timeout: 60 * time.Second}

// fetchSignature 从待测渠道获取一条带 thinking 的响应,返回签名
func fetchSignature(base, key, model string) (string, error) {
payload := map[string]any{
"model": model,
"max_tokens": 512,
"messages": []map[string]any{{"role": "user", "content": "1071和462的最大公约数是多少?请展示你的思考过程。"}},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", base+"/v1/messages", bytes.NewReader(body))
req.Header.Set("content-type", "application/json")
req.Header.Set("x-api-key", key)
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var d struct {
Content []struct {
Type string `json:"type"`
Signature string `json:"signature"`
} `json:"content"`
}
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
return "", err
}
for _, b := range d.Content {
if b.Type == "thinking" && b.Signature != "" {
return b.Signature, nil
}
}
return "", fmt.Errorf("响应中没有 thinking 块")
}

// modelID 解码签名内的模型明文
func modelID(sig string) string {
raw, err := base64.StdEncoding.DecodeString(sig)
if err != nil {
return ""
}
re := regexp.MustCompile(`claude-[a-z0-9.\-]+`)
return re.FindString(string(raw))
}

// verifyOnOpenRouter 交叉验证签名真实性
func verifyOnOpenRouter(sig string) bool {
payload := map[string]any{
"model": "anthropic/claude-fable-5",
"max_tokens": 64,
"provider": map[string]any{"order": []string{"anthropic"}},
"messages": []map[string]any{
{"role": "user", "content": "2+2等于多少?"},
{"role": "assistant", "content": []map[string]any{
{"type": "thinking", "thinking": "", "signature": sig},
{"type": "text", "text": "4"},
}},
{"role": "user", "content": "现在加3等于多少?"},
},
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", "https://openrouter.ai/api/v1/messages", bytes.NewReader(body))
req.Header.Set("content-type", "application/json")
req.Header.Set("x-api-key", os.Getenv("OPENROUTER_KEY"))
req.Header.Set("anthropic-version", "2023-06-01")
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return resp.StatusCode == 200
}

func main() {
base := os.Getenv("TARGET_BASE") // 待测渠道地址,比如 http://1.2.3.4:3000
key := os.Getenv("TARGET_KEY") // 渠道的 API key
model := os.Getenv("TARGET_MODEL") // 请求的模型名,比如 claude-fable-5

// 每 2 小时自动跑一次,连续几天没异常,基本就可以放心用了
ticker := time.NewTicker(2 * time.Hour)
log.Println("巡检已启动,每 2 小时执行一次...")
for range ticker.C {
sig, err := fetchSignature(base, key, model)
if err != nil {
log.Println("获取签名失败:", err)
continue
}
mid := modelID(sig)
log.Printf("签名明文模型: %s\n", mid)
if mid == "" || !strings.Contains(mid, strings.Split(model, "-")[1]) {
log.Println("警告:签名明文与请求模型不符,疑似套壳!")
continue
}
if verifyOnOpenRouter(sig) {
log.Println("交叉验证通过:签名是官方的,模型也对得上")
} else {
log.Println("警告:交叉验证失败,签名疑似伪造!")
}
}
}

把环境变量配好:TARGET_BASE(待测渠道地址)、TARGET_KEY(渠道的 key)、TARGET_MODEL(模型名)、OPENROUTER_KEY(你的 OpenRouter key)。程序跑起来之后,扔在服务器后台或者挂在 systemd / cron 里就行,异常会在日志里直接暴露。

巡检频率的话,每 1-2 小时一次足够了——太频繁了浪费钱(OpenRouter 也要走 token 计费),太稀疏了发现问题的窗口期太长。如果你特别谨慎,可以再配一个告警(比如飞书 Webhook 或者钉钉机器人),出异常第一时间通知你。

总结

整套方法说穿了就三步:

  1. 解码签名明文——先筛一遍,模型标识对不上的直接淘汰
  2. 交叉验证——把签名发给 OpenRouter 官方验证,200 是真,400 是假
  3. 定时巡检——写个程序让它自己跑,每 1-2 小时一次,连续几天没异常就稳了

背后的原理其实就一个:签名用的是 Anthropic 的私钥,跨平台兼容,只有官方能生成,而且生成时就把模型身份写死在里面了。这是目前能想到的,验证中转站模型真伪最硬核的依据。

踩过的坑

一些实操中容易翻车的地方,列在这里供参考:

  • adaptive thinking 的问题:如果你用的是 adaptive thinking 模式(而不是手动设 thinking budget),简单问题(比如”2+2 等于几”)模型可能直接跳过思考、不返回 thinking 块。测试的 prompt 需要有足够的复杂度(比如辗转相除法求最大公约数、多步逻辑推理这类),才能稳定触发模型输出思考内容。
  • thinking 被省略但签名还在:有些渠道为了省 token,会设置 display: "omitted",这时候返回的 thinking 文本是空的,但 signature 字段依然存在,不影响提取和验证。
  • OpenRouter 记得强制路由:调 OpenRouter 的 Anthropic 端点时,务必带上 provider: {"order": ["anthropic"]},不然请求可能会被分发到其他供应商(比如通过 Bedrock 或 GCP 托管的其他实例),虽然不影响验证结果,但为了严谨还是强制走 Anthropic 直连最好。
  • OpenRouter 的费用:交叉验证每次会消耗少量 token(主要是 prompt 的输入 token),按 OpenRouter 的计费标准,单次验证大概几分钱人民币。每 2 小时跑一次,一个月下来也就几块钱,比用着假模型亏的钱少得多。
  • 签名明文和模型名的对应关系:签名里的模型标识是 Anthropic 内部代号,不一定跟 API 的模型名完全一致。比如 claude-fable-5 的内部代号可能是 claude-fable-58(注意数字部分),这个需要你自己先拿一个确认是真的签名来看一下具体长什么样。
  • 网络超时和重试:中转站有时候不太稳定,fetch 签名的时候偶尔会超时。生产环境的代码最好加上重试逻辑(比如失败后间隔 30 秒重试一次,最多 3 次),别因为一次网络抖动就误报。

许可协议

本文由 ROYWANG 原创,采用 CC BY-NC-SA 4.0 协议。转载请注明出处。

PERMALINK

https://roy.wang/claude-channel-verification/

Comments