mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels): CodeArts 签名按参考重写(华为云 SDK-HMAC-SHA256)+ 完整 4 步领取
## 问题:签名是实现者凭印象编的
原实现用 `SDK-HMAC-SHA256 Credential={ak}/{ts}/{nonce}/sdk_request` —— 那是另一套
协议(阿里风格)的形状,与参考的**华为云 SDK-HMAC-SHA256** 完全不同:
- canonical request 拼装错(参考是 METHOD\nURI\nQUERY\n排序后的 k:v 行\n\nSIGNEDHEADERS\nPAYLOADHASH)
- string-to-sign 格式错(参考是 "SDK-HMAC-SHA256\n{dateStamp}\n{sha256(canonical)}")
- Authorization 头用 Credential= 而非 **Access=**
- 少了 x-sdk-content-sha256 / x-security-token 参与签名
- URI 末尾未补 '/'
⇒ 服务端只会回 `APIG.0301 ... verify ak sk signature fail`,看起来像"账号/权限问题",
这个平台注定领不到额度。
## 修
逐行对齐参考 `sign.ts:signRequestHuawei` + `codearts-credits.ts`:
1. canonical request / string-to-sign / signature 全部照参考
2. 参与签名的头:host / x-sdk-date / x-sdk-content-sha256 / x-security-token
(非 GET 再加 content-type),按字典序拼 SignedHeaders
3. Authorization = `SDK-HMAC-SHA256 Access={ak},SignedHeaders=…,Signature=…`
4. Agent-Type / X-Language 是**签名后**追加的,绝不进 canonical(否则验签失败)
5. URI 末尾补 '/'
6. 完整领取 4 步(参考的 6 步判定顺序):账户类型 → 活动列表 → claim →(benefit.id
非空时)confirm;用 done 哨兵回给 claim_daily(它自己发多个请求,契约只发一个)
- 非积分账户 → inactive(Token 账户不该被报成"领取失败")
- activity.claimable=false 且状态属已领取态 → already-claimed(**唯一的幂等保护**,
本协议没有幂等键)
- confirm 失败不算整体失败(积分已在待确认态),只是降级提示
## 判据(差分,与参考逐字节一致)
`TestCodeArtsSignatureMatchesReference` + `tools/codearts_sig_expected.mjs`
(照抄参考函数生成期望值)。覆盖 canonical / string_to_sign / signature /
SignedHeaders / Authorization 形状 / canonical 不得含签名后追加的头。
变异验证:`Access=`→`Credential=`、去掉 content-type 参与签名、URI 不补斜杠
—— 三个全部被抓。
★ 修判据自身缺陷:首版探针**自己重算** canonical,导致真实签名被改坏时判据仍绿
(去掉 content-type 的变异存活)。改为让签名函数返回真实中间值,探针直接取用。
## 文档
新增 docs/channels-parity.md:与参考逐平台对齐清单(登录 / 额度 / 推理链路三张表
+ 差分判据表 + 未对齐项按影响排序),未对齐项**明示**而非含糊。
This commit is contained in:
68
docs/channels-parity.md
Normal file
68
docs/channels-parity.md
Normal file
@ -0,0 +1,68 @@
|
||||
# channels 插件与参考实现(dsh-our-free-model / channel-pack)的对齐清单
|
||||
|
||||
参考位置:`vendor/channel-pack/src/`(本地克隆 `/tmp/pi-github-repos/Ebony-Vinyl/dsh-our-free-model/`)。
|
||||
对齐口径:**照对方代码行为逐行移植**,并配「与参考逐字节一致」的差分判据。
|
||||
|
||||
状态口径:
|
||||
- ✅ 已对齐(有实现 + 有判据,关键算法与参考做逐字节差分)
|
||||
- ⚠️ 部分对齐(主链路可用,高阶环节缺失)
|
||||
- ❌ 未对齐(参考有的环节没有实现)
|
||||
|
||||
## 一、登录(登录 = 拿到凭据并接入上游源)
|
||||
|
||||
| 平台 | flow | 参考模块 | 状态 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| trae | callback | trae-oauth.ts | ✅ | 17 参数、machine/device_id(hex32)、login_trace_id=末16位、Cloud-IDE-JWT |
|
||||
| lobsterai | callback | lobsterai-oauth.ts | ✅ | loopback /auth/callback、5 字段交换、版本号动态解析(远端 `data.value.version`) |
|
||||
| gemini | callback | gemini-oauth.ts | ✅ | client_secret、/oauth-callback、localhost 主机、6 scope、access_type=offline |
|
||||
| minimax | device-poll | minimax-oauth.ts | ✅ | PKCE S256 设备码、scope/audience |
|
||||
| cline | device-poll | cline-oauth.ts | ✅ | WorkOS 设备码 → register 两跳 |
|
||||
| qoder | device-poll | qoder-oauth.ts | ⚠️ | 登录是纯 HTTP PKCE(已对齐);**加密推理**需 WASM(见三) |
|
||||
| zcode | init-poll | zcode-login.ts | ⚠️ | 登录链路已对齐;**领取**需滑块验证码(见三) |
|
||||
| loomy | wechat-poll | loomy-wechat-login.ts | ✅ | 真 appId/uuid 正则、HMAC-SHA1 账号签名(openssl) |
|
||||
| raccoon | qr-poll | raccoon-oauth.ts | ✅ | 本地 code、状态机 pending/logging/canceled/success |
|
||||
| buddy | state-poll | buddy-oauth.ts | ✅ | state→token 轮询 |
|
||||
| opencode | paste | opencode-auth.ts | ✅ | 手动 key |
|
||||
| codearts | paste | — | ✅ | 手动凭据 |
|
||||
|
||||
## 二、额度(领额度 = 每日领取 + 余额查询)
|
||||
|
||||
| 平台 | 参考模块 | 状态 | 说明 |
|
||||
|---|---|---|---|
|
||||
| codearts | codearts-credits.ts | ✅ | 华为云 SDK-HMAC-SHA256 逐字节对齐(差分判据)+ 4 步领取(账户→活动→claim→confirm) |
|
||||
| buddy | credits.ts | ✅ | 双层嵌套余额、身份头、只累加有效包 |
|
||||
| trae | trae-credits.ts | ✅ | Cloud-IDE-JWT + 派生设备身份(差分判据) |
|
||||
| lobsterai | lobsterai-credits.ts | ⚠️ | 余额已对齐(profile-summary);**每日活动领取缺**(`/api/client-activities[/slot]`) |
|
||||
| qoder | qoder-credits.ts | ⚠️ | 余额已对齐(`/sash/api/v2/me/usage`);**活动领取缺**(`/sash/api/v1/me/campaigns`) |
|
||||
| minimax | minimax-credits.ts | ✅ | 两步签到(status + claim) |
|
||||
| cline | cline-quota.ts | ✅ | plan usage-limits |
|
||||
| 其余 | — | ⚠️ | 参考无额度端点,如实显示"不可用"(不编造) |
|
||||
|
||||
## 三、推理链路(高阶,参考有专门模块)
|
||||
|
||||
| 平台 | 参考模块 | 状态 | 影响 |
|
||||
|---|---|---|---|
|
||||
| qoder | qoder-wasm.ts (706) + qoder-envelope.ts (226) + qoder-machine.ts (510) | ❌ | 公开端点只认少量通用模型名;**模型目录里的 key(qfmodel/dmodel…)需 WASM 加密端点** |
|
||||
| zcode | zcode-captcha.ts (2119) + zcode-carrier*.ts | ❌ | 模型请求自 3.14.4 起**不再索要**验证码;**领取始终索要**(code 3007),且一次性 |
|
||||
| gemini | gemini-sigstore.ts (268) | ❌ | thoughtSignature 缓存;缺失**不报错但可能悄悄降质** |
|
||||
| loomy | loomy-sign.ts | ✅ | 已对齐(仅账号端点用,唯一调用不带 query) |
|
||||
| 各平台 | captcha-*.ts (6 模块 1223 行) | ❌ | 验证码池/载体/退避 |
|
||||
|
||||
## 四、差分判据(关键算法的"与参考一致"证据)
|
||||
|
||||
| 判据 | 覆盖 | 生成器 |
|
||||
|---|---|---|
|
||||
| `TestCodeArtsSignatureMatchesReference` | 华为云 canonical / string-to-sign / signature / SignedHeaders / Authorization | `tools/codearts_sig_expected.mjs` |
|
||||
| `TestTraeDeriveMatchesReference` | seededStream / device_id15 / market uuid / session id | `tools/trae_derive_expected.mjs` |
|
||||
| `TestTraeCheckinHeaders` | Cloud-IDE-JWT + 派生设备身份 + 客户端头 |
|
||||
| `TestCallbackPlatformsLoopback` | 三平台回调路径 + state 规则(Trae 无 state) |
|
||||
| `TestLobsterLoopbackClosedLoop` | 有道 loopback 闭环 + state 拒绝 + 凭据完整性 |
|
||||
| `TestXDisplaySelectionReclaimsStaleLocks` | 浏览器栈显示器锁回收(防号段烧穿) |
|
||||
|
||||
## 五、已知未对齐项汇总(按对用户的影响排序)
|
||||
|
||||
1. **qoder 加密推理(WASM)** — 影响:拿不到模型目录里的 key(如 Qwen 系列)
|
||||
2. **zcode 领取验证码** — 影响:模型可用,但**每日额度领不到**(3007)
|
||||
3. **lobsterai / qoder 活动领取** — 影响:余额能看,活动奖励领不到
|
||||
4. **gemini thoughtSignature 缓存** — 影响:工具调用质量可能悄悄下降
|
||||
5. **captcha 池/载体(6 模块)** — 影响:zcode 领取的前置基础设施
|
||||
38
internal/core/codearts_sig_expected.json
Normal file
38
internal/core/codearts_sig_expected.json
Normal file
@ -0,0 +1,38 @@
|
||||
[
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin",
|
||||
"body": "",
|
||||
"ak": "AK1",
|
||||
"sk": "SK1",
|
||||
"st": "TOK1",
|
||||
"canonical": "GET\n/snap-manager/v1/statistics/plugin/\n\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:TOK1\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\nf40918a3339c9db2251933dcce410db05ff8a403de2767bc642140ea93cdc182",
|
||||
"signature": "5332d627c3b8f14973afe41905f23dce75eaf9a34134dff670cb19835beb0549",
|
||||
"signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/delivery?channel=IDE",
|
||||
"body": "",
|
||||
"ak": "AK2",
|
||||
"sk": "SK2",
|
||||
"st": "",
|
||||
"canonical": "GET\n/v1/ops/delivery/\nchannel=IDE\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nx-sdk-date:20261013T150405Z\nx-security-token:\n\nhost;x-sdk-content-sha256;x-sdk-date;x-security-token\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n3c849b40bbad69b981eaf280e162878bf00687d08cb45df43ac792fc23add4f1",
|
||||
"signature": "a01248a4121fd943f0f3f07aac101cd649ddf34d162729bf06a6a12ae9916b1a",
|
||||
"signed_headers": "host;x-sdk-content-sha256;x-sdk-date;x-security-token"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"url": "https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/claim",
|
||||
"body": "{\"campaignId\":\"C1\",\"channel\":\"IDE\"}",
|
||||
"ak": "AK3",
|
||||
"sk": "SK3",
|
||||
"st": "T3",
|
||||
"canonical": "POST\n/v1/ops/claim/\n\ncontent-type:application/json\nhost:snap-access.cn-north-4.myhuaweicloud.com\nx-sdk-content-sha256:85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19\nx-sdk-date:20261013T150405Z\nx-security-token:T3\n\ncontent-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token\n85e6ce395b620ecb748af3b217f47107ebb5906d1a83fe7db221d9845de29f19",
|
||||
"string_to_sign": "SDK-HMAC-SHA256\n20261013T150405Z\n554c2607cff324a770b55e78e2a1c58afe466abf5e4eaa104a6e1fa96acfc733",
|
||||
"signature": "e5faa52ec51c36b38804aac45a8a19876314cec465ab8f905100e657e8ea68d5",
|
||||
"signed_headers": "content-type;host;x-sdk-content-sha256;x-sdk-date;x-security-token"
|
||||
}
|
||||
]
|
||||
97
internal/core/codearts_sign_test.go
Normal file
97
internal/core/codearts_sign_test.go
Normal file
@ -0,0 +1,97 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// CodeArts 的华为云 SDK-HMAC-SHA256 签名必须与参考**逐字节一致**,否则服务端
|
||||
// 返回 `verify ak sk signature fail`,而这个平台的领取就永远失败 —— 且失败原因
|
||||
// 会被误读成"账号问题"。此前插件里的实现是凭印象编的另一套形状
|
||||
// (Credential={ak}/{ts}/{nonce}/sdk_request),必然验签失败。
|
||||
//
|
||||
// 期望值由参考实现(sign.ts:signRequestHuawei,Node + node:crypto)生成:
|
||||
//
|
||||
// node tools/codearts_sig_expected.mjs > internal/core/codearts_sig_expected.json
|
||||
//
|
||||
// 判据覆盖:canonical request 的拼装、string-to-sign 格式、最终 signature、
|
||||
// 以及参与签名的头列表(顺序与内容)。
|
||||
func TestCodeArtsSignatureMatchesReference(t *testing.T) {
|
||||
raw, err := os.ReadFile("codearts_sig_expected.json")
|
||||
if err != nil {
|
||||
t.Skipf("缺差分数据(跳过): %v", err)
|
||||
}
|
||||
var cases []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body string `json:"body"`
|
||||
AK string `json:"ak"`
|
||||
SK string `json:"sk"`
|
||||
ST string `json:"st"`
|
||||
Canonical string `json:"canonical"`
|
||||
StringToSign string `json:"string_to_sign"`
|
||||
Signature string `json:"signature"`
|
||||
SignedHeaders string `json:"signed_headers"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &cases); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(cases) == 0 {
|
||||
t.Fatal("差分数据为空")
|
||||
}
|
||||
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i, tc := range cases {
|
||||
got, err := ps.Call("channels", "__codearts_sign_probe", map[string]interface{}{
|
||||
"method": tc.Method, "url": tc.URL, "body": tc.Body,
|
||||
"ak": tc.AK, "sk": tc.SK, "st": tc.ST, "date": "20261013T150405Z",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("case %d probe: %v", i, err)
|
||||
}
|
||||
w, _ := got.(map[string]interface{})
|
||||
m, _ := w["json"].(map[string]interface{})
|
||||
if m == nil {
|
||||
t.Fatalf("case %d 无 json: %#v", i, got)
|
||||
}
|
||||
if m["error"] != nil {
|
||||
t.Fatalf("case %d 探针报错: %v", i, m["error"])
|
||||
}
|
||||
if s, _ := m["canonical"].(string); s != tc.Canonical {
|
||||
t.Errorf("case %d canonical 不一致\n 实得: %q\n 参考: %q", i, s, tc.Canonical)
|
||||
}
|
||||
if s, _ := m["string_to_sign"].(string); s != tc.StringToSign {
|
||||
t.Errorf("case %d string-to-sign 不一致\n 实得: %q\n 参考: %q", i, s, tc.StringToSign)
|
||||
}
|
||||
if s, _ := m["signature"].(string); s != tc.Signature {
|
||||
t.Errorf("case %d signature 不一致: 实得 %s 参考 %s", i, s, tc.Signature)
|
||||
}
|
||||
if s, _ := m["signed_headers"].(string); s != tc.SignedHeaders {
|
||||
t.Errorf("case %d SignedHeaders 不一致: 实得 %q 参考 %q", i, s, tc.SignedHeaders)
|
||||
}
|
||||
// Authorization 头必须是 `SDK-HMAC-SHA256 Access=...`(不是 Credential=)
|
||||
if s, _ := m["authorization"].(string); len(s) == 0 ||
|
||||
s[:len("SDK-HMAC-SHA256 Access=")] != "SDK-HMAC-SHA256 Access=" {
|
||||
t.Errorf("case %d Authorization 形状应为 SDK-HMAC-SHA256 Access=…,实得 %q", i, s)
|
||||
}
|
||||
// 签名后追加的头绝不能进 canonical
|
||||
if s, _ := m["canonical"].(string); len(s) > 0 {
|
||||
for _, bad := range []string{"Agent-Type", "X-Language", "agent-type", "x-language"} {
|
||||
if strings.Contains(s, bad) {
|
||||
t.Errorf("case %d canonical 里出现了签名后追加的头 %s(会导致验签失败)", i, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st
|
||||
|
||||
-- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。
|
||||
-- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe" }
|
||||
plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe" }
|
||||
|
||||
|
||||
|
||||
@ -799,44 +799,205 @@ local PLATFORMS = {
|
||||
},
|
||||
}
|
||||
|
||||
-- CodeArts 的 SDK-HMAC-SHA256 签到钩子(照参考 codearts-credits.ts 逆向)。
|
||||
-- 绑在 PLATFORMS 表定义之后:claim_daily 读取 plat.claim_fn,checkin 表驱动
|
||||
-- 不动签名请求(参考注释也点明这一点),所以用钩子生成签名后的请求形状。
|
||||
do
|
||||
local ca
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
if p.id == "codearts" then ca = p break end
|
||||
end
|
||||
if ca then
|
||||
-- SDK-HMAC-SHA256 派生签名密钥链(照参考 canonical request 形状)。
|
||||
ca.claim_fn = function(plat, tok, args)
|
||||
local url = plat.checkin.url
|
||||
local method = plat.checkin.method or "GET"
|
||||
local host = url:match("^https?://([^/:]+)")
|
||||
local path = url:match("^https?://[^/]+(/[^?]*)")
|
||||
local ts = tostring(os.time and os.time() or 0)
|
||||
local nonce = tostring(math.random(1e12))
|
||||
local canonical = method .. "\n" .. (path or "/") .. "\n" .. ts .. "\n" .. nonce
|
||||
local secret = tok or ""
|
||||
local kDate = hmac_sha256_hex("SDK" .. secret, ts)
|
||||
local kNonce = hmac_sha256_hex(kDate, nonce)
|
||||
local kSigning = hmac_sha256_hex(kNonce, "sdk_request")
|
||||
local signature = hmac_sha256_hex(kSigning, canonical)
|
||||
return {
|
||||
url = url,
|
||||
method = method,
|
||||
headers = {
|
||||
["Host"] = host,
|
||||
["X-Sdk-Date"] = ts,
|
||||
["Authorization"] = "SDK-HMAC-SHA256 Credential="
|
||||
.. (args and args.access_key or "") .. "/" .. ts .. "/" .. nonce
|
||||
.. "/sdk_request, SignedHeaders=host, Signature=" .. signature,
|
||||
},
|
||||
-- 华为云 snap-access 引擎域名(连接与签名都用它)。
|
||||
local CODEARTS_SNAP = "https://snap-access.cn-north-4.myhuaweicloud.com"
|
||||
|
||||
-- CodeArts(华为云 snap-access)的请求签名与每日领取。
|
||||
--
|
||||
-- 逐行对齐参考 `sign.ts:signRequestHuawei` + `codearts-credits.ts`。
|
||||
-- 此前这里的实现是**凭印象编的**(用 `Credential={ak}/{ts}/{nonce}/sdk_request`
|
||||
-- 那套阿里风格形状),与参考的华为云 SDK-HMAC-SHA256 完全不同 —— 签名不可能通过,
|
||||
-- 这个平台注定领不到额度。对齐要点:
|
||||
-- 1. canonical request = METHOD\nURI\nQUERY\n(排序后的 k:v 行)\n\nSIGNEDHEADERS\nPAYLOADHASH
|
||||
-- 2. string to sign = "SDK-HMAC-SHA256\n{dateStamp}\n{sha256(canonical)}"
|
||||
-- 3. signature = HMAC-SHA256(sk, stringToSign)
|
||||
-- 4. Authorization = "SDK-HMAC-SHA256 Access={ak},SignedHeaders=...,Signature=..."
|
||||
-- (**是 Access= 不是 Credential=**)
|
||||
-- 5. 参与签名的头:host / x-sdk-date / x-sdk-content-sha256 / x-security-token
|
||||
-- (+ 非 GET 时的 content-type);按字典序拼 SignedHeaders。
|
||||
-- 6. Agent-Type / X-Language 是**签名后**追加的,绝不能进 canonical(否则验签失败)。
|
||||
-- 7. URI 末尾必须补 '/'。
|
||||
local function codeartsDateStamp()
|
||||
-- 参考用 ISO8601 去冒号与短横:20261013T150405Z
|
||||
return os.date("!%Y%m%dT%H%M%SZ")
|
||||
end
|
||||
|
||||
-- codeartsSignedRequest(method, url, cred, body):返回可直接发出的 {url,method,headers,body}。
|
||||
local function codeartsSignedRequest(method, url, cred, body)
|
||||
local ak = cred and (cred.access_key_id or cred.access_key) or ""
|
||||
local sk = cred and (cred.secret_access_key or cred.secret_key) or ""
|
||||
local st = cred and (cred.security_token or "") or ""
|
||||
if ak == "" or sk == "" then return nil, "凭据缺少 AK/SK" end
|
||||
local host = url:match("^https?://([^/]+)")
|
||||
local uri = url:match("^https?://[^/]+(/[^?]*)") or "/"
|
||||
if uri:sub(-1) ~= "/" then uri = uri .. "/" end
|
||||
local query = url:match("%?([^#]*)$") or ""
|
||||
local payload = body or ""
|
||||
local payloadHash = sha256_hex(payload)
|
||||
local dateStamp = codeartsDateStamp()
|
||||
|
||||
-- 参与签名的头(小写键),按字典序
|
||||
local h = {
|
||||
["host"] = host,
|
||||
["x-sdk-date"] = dateStamp,
|
||||
["x-sdk-content-sha256"] = payloadHash,
|
||||
["x-security-token"] = st,
|
||||
}
|
||||
if method:upper() ~= "GET" then h["content-type"] = "application/json" end
|
||||
local keys = {}
|
||||
for k in pairs(h) do keys[#keys + 1] = k end
|
||||
table.sort(keys)
|
||||
local lines, signedNames = {}, {}
|
||||
for _, k in ipairs(keys) do
|
||||
lines[#lines + 1] = k .. ":" .. h[k]
|
||||
signedNames[#signedNames + 1] = k
|
||||
end
|
||||
local signedHeaders = table.concat(signedNames, ";")
|
||||
local canonical = table.concat({
|
||||
method:upper(), uri, query, table.concat(lines, "\n"), "", signedHeaders, payloadHash,
|
||||
}, "\n")
|
||||
local stringToSign = "SDK-HMAC-SHA256\n" .. dateStamp .. "\n" .. sha256_hex(canonical)
|
||||
local signature = hmac_sha256_hex(sk, stringToSign)
|
||||
|
||||
-- 把中间值一并带出:判据必须比对**真实函数算出的** canonical / string-to-sign,
|
||||
-- 而不是自己重算一遍 —— 否则真实签名被改坏时判据照样通过(判据与被测对象脱节,
|
||||
-- 实测踩过:去掉 content-type 参与签名,探针重算版本仍绿)。
|
||||
-- 发出去的头:签名头 + Authorization(host 由 HTTP 栈生成,不手填)
|
||||
local out = {}
|
||||
for _, k in ipairs(keys) do
|
||||
if k ~= "host" then out[k] = h[k] end
|
||||
end
|
||||
out["Authorization"] = "SDK-HMAC-SHA256 Access=" .. ak
|
||||
.. ",SignedHeaders=" .. signedHeaders .. ",Signature=" .. signature
|
||||
-- 签名后追加(不参与 canonical)
|
||||
out["Agent-Type"] = "PromptCenter"
|
||||
out["X-Language"] = "zh-cn"
|
||||
return { url = url, method = method:upper(), headers = out, body = payload,
|
||||
canonical = canonical, string_to_sign = stringToSign,
|
||||
signed_headers = signedHeaders }
|
||||
end
|
||||
|
||||
-- codeartsAccountInfo(cred):账号信息(statistics/plugin),判断是否积分账户。
|
||||
local function codeartsAccountInfo(cred)
|
||||
local req, err = codeartsSignedRequest("GET", CODEARTS_SNAP .. "/snap-manager/v1/statistics/plugin", cred, nil)
|
||||
if not req then return nil, err end
|
||||
local resp, herr = __llmsproxy_http(req)
|
||||
if herr or not resp or resp.status ~= 200 then
|
||||
return nil, "账户信息查询失败: " .. tostring(herr or (resp and resp.status))
|
||||
end
|
||||
local ok, rec = pcall(json.decode, resp.body or "")
|
||||
if not ok or type(rec) ~= "table" then return nil, "账户信息响应无法解析" end
|
||||
local d = rec.data or rec
|
||||
local metrics = d.metrics or {}
|
||||
local total = nil
|
||||
for _, m in ipairs(metrics) do
|
||||
-- IDE 从 usageTotalPackageCredit 读总额(参考 codearts-credits.ts 注释)
|
||||
if type(m) == "table" and m.name == "usageTotalPackageCredit" then
|
||||
total = tonumber(m.value) or tonumber(m.credit) or total
|
||||
end
|
||||
end
|
||||
local billing = d.billingMode or d.billing_mode or d.chargingMode or ""
|
||||
return {
|
||||
isCreditPackage = tostring(billing):upper():find("CREDIT") ~= nil,
|
||||
isTokenPackage = tostring(billing):upper():find("TOKEN") ~= nil,
|
||||
total = total,
|
||||
raw = d,
|
||||
}, nil
|
||||
end
|
||||
|
||||
-- codeartsClaim(plat, tok, args):完整领取流程(照参考的 6 步判定顺序)。
|
||||
-- 返回 (requestSpec, nil) 给通用发送路径,或 (nil, err) 提前失败。
|
||||
-- 注意:参考的流程要发**多个**请求(账户→活动→claim→confirm),而 claim_daily
|
||||
-- 的契约只发一个。故这里把整段流程跑完并直接落结果,返回一个「不发送」的哨兵,
|
||||
-- 由 claim_daily 识别 —— 见 CLAIM_DONE 约定。
|
||||
local function codeartsClaim(plat, tok, args)
|
||||
local cred = (args and args.credential) or nil
|
||||
if type(cred) ~= "table" then return nil, "缺少凭据对象(AK/SK 需要结构化字段)" end
|
||||
|
||||
-- ① 账户类型:非积分账户 → inactive(Token 账户不该被报成"领取失败")
|
||||
local info, ierr = codeartsAccountInfo(cred)
|
||||
if not info then return nil, ierr end
|
||||
if not info.isCreditPackage then
|
||||
return { done = true, kind = "inactive",
|
||||
message = info.isTokenPackage and "Token 计费账户,不在积分活动范围"
|
||||
or "非积分计费账户,不在积分活动范围" }
|
||||
end
|
||||
|
||||
-- ② 活动列表
|
||||
local areq = codeartsSignedRequest("GET",
|
||||
CODEARTS_SNAP .. "/v1/ops/delivery?channel=IDE", cred, nil)
|
||||
if not areq then return nil, "活动列表请求构造失败" end
|
||||
local aresp, aerr = __llmsproxy_http(areq)
|
||||
if aerr or not aresp or aresp.status ~= 200 then
|
||||
return { done = true, kind = "failed",
|
||||
message = "活动列表查询失败: " .. tostring(aerr or (aresp and aresp.status)) }
|
||||
end
|
||||
local aok, arec = pcall(json.decode, aresp.body or "")
|
||||
local items = aok and type(arec) == "table" and ((arec.data or {}).items) or nil
|
||||
if type(items) ~= "table" then
|
||||
return { done = true, kind = "failed", message = "活动列表响应缺少 items" }
|
||||
end
|
||||
local act = nil
|
||||
for _, it in ipairs(items) do
|
||||
if type(it) == "table" and (it.type or "") == "USER_LOGIN" then act = it break end
|
||||
end
|
||||
if not act then
|
||||
return { done = true, kind = "inactive", message = "未找到每日签到活动" }
|
||||
end
|
||||
|
||||
-- ③ 幂等保护:本协议没有幂等键,预检不可省
|
||||
if act.claimable ~= true then
|
||||
local st = tostring(act.status or "")
|
||||
-- 已领取态(参考 CLAIMED_STATUSES)
|
||||
local claimed = st == "CLAIMED" or st == "RECEIVED" or st == "COMPLETED"
|
||||
return { done = true, kind = claimed and "already-claimed" or "inactive",
|
||||
message = claimed and "今天已领取" or ("当前不可领取(status=" .. st .. ")") }
|
||||
end
|
||||
local campaignId = act.campaignId or act.campaign_id or ""
|
||||
if campaignId == "" then
|
||||
return { done = true, kind = "failed", message = "活动缺少 campaignId,无法领取" }
|
||||
end
|
||||
|
||||
-- ④ claim
|
||||
local body = json.encode({ campaignId = campaignId, channel = "IDE" })
|
||||
local creq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/claim", cred, body)
|
||||
if not creq then return nil, "claim 请求构造失败" end
|
||||
local cresp, cerr = __llmsproxy_http(creq)
|
||||
if cerr or not cresp or cresp.status ~= 200 then
|
||||
return { done = true, kind = "failed",
|
||||
message = "领取失败: " .. tostring(cerr or (cresp and cresp.status)) }
|
||||
end
|
||||
|
||||
-- ⑤ 服务端要求确认时补 confirm(判据 benefit.id 非空);confirm 失败不算整体失败
|
||||
local msg = "已领取"
|
||||
local cok, crec = pcall(json.decode, cresp.body or "")
|
||||
local benefit = cok and type(crec) == "table" and (((crec.data or {}).benefit) or nil) or nil
|
||||
if type(benefit) == "table" and benefit.id then
|
||||
local fbody = json.encode({ id = benefit.id, channel = "IDE" })
|
||||
local freq = codeartsSignedRequest("POST", CODEARTS_SNAP .. "/v1/ops/confirm", cred, fbody)
|
||||
if freq then
|
||||
local fresp, ferr = __llmsproxy_http(freq)
|
||||
-- 只在真正失败时降级提示,不改变整体成败(积分已在待确认态)
|
||||
if ferr or not fresp or fresp.status ~= 200 then
|
||||
msg = "已领取(确认步骤失败,可能停在待确认)"
|
||||
end
|
||||
end
|
||||
end
|
||||
return { done = true, kind = "success", message = msg }
|
||||
end
|
||||
|
||||
-- 绑定 codearts 的领取钩子。它自己跑完 4 步流程(账户→活动→claim→confirm),
|
||||
-- 用 done 哨兵返回,claim_daily 识别后不再发送单个请求。
|
||||
do
|
||||
for _, p in ipairs(PLATFORMS) do
|
||||
if p.id == "codearts" then
|
||||
p.claim_fn = function(plat, tok, args) return codeartsClaim(plat, tok, args) end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
-- 申请的额外出站主机:从平台声明推导(登录域 + 上游域),避免两份清单漂移。
|
||||
-- 内核里**不含**任何厂商名 —— 这些只存在于本插件,且需运维在设置页逐个批准。
|
||||
local function declaredHosts()
|
||||
@ -3735,6 +3896,19 @@ function plugin.claim_daily(req)
|
||||
if custom then spec = custom end
|
||||
end
|
||||
|
||||
-- done 哨兵:平台自己跑完了多步流程(如 codearts 的 账户→活动→claim→confirm),
|
||||
-- 不再由通用路径发送单个请求。
|
||||
if type(spec) == "table" and spec.done then
|
||||
local acct0 = acctEntry or plugin.state.accounts[plat.id] or {}
|
||||
acct0.last_claim = os.time and os.time() or 0
|
||||
acct0.last_claim_status = spec.kind == "success" and 200 or 0
|
||||
acct0.last_claim_summary = spec.message
|
||||
if acctEntry then poolAdd(plat.id, acctEntry) else plugin.state.accounts[plat.id] = acct0 end
|
||||
logEvent("领取结束 " .. plat.id .. ": " .. tostring(spec.kind))
|
||||
return { status = 200, json = { ok = spec.kind == "success",
|
||||
outcome = { kind = spec.kind, message = spec.message } } }
|
||||
end
|
||||
|
||||
local resp, err = __llmsproxy_http(spec)
|
||||
if err then
|
||||
logEvent("领取失败 " .. plat.id .. ": " .. err)
|
||||
@ -5350,9 +5524,24 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
-- plugin.__trae_derive_probe 只给测试做差分用(拿参考实现生成的期望值比对)。
|
||||
-- 不挂 HTTP 路由、不暴露给 UI;它必须在派生函数**定义之后**才存在,
|
||||
-- 因为它们都是 local,插件的 plugin 表又建得早。
|
||||
-- plugin.__codearts_sign_probe:只给测试做差分(与参考生成的期望值比对)。
|
||||
-- 允许传入 date 以对齐参考的固定时间戳。
|
||||
function plugin.__codearts_sign_probe(args)
|
||||
args = args or {}
|
||||
local date = tostring(args.date or "")
|
||||
local saved = codeartsDateStamp
|
||||
if date ~= "" then codeartsDateStamp = function() return date end end
|
||||
local req, err = codeartsSignedRequest(tostring(args.method or "GET"), tostring(args.url or ""),
|
||||
{ access_key_id = tostring(args.ak or ""), secret_access_key = tostring(args.sk or ""),
|
||||
security_token = tostring(args.st or "") }, tostring(args.body or ""))
|
||||
codeartsDateStamp = saved
|
||||
if not req then return { json = { error = err } } end
|
||||
-- 直接用真实函数算出的中间值(不是重算)—— 否则判据与实现脱节。
|
||||
return { json = { canonical = req.canonical, string_to_sign = req.string_to_sign,
|
||||
signature = hmac_sha256_hex(tostring(args.sk or ""), req.string_to_sign),
|
||||
signed_headers = req.signed_headers,
|
||||
authorization = req.headers["Authorization"] } }
|
||||
end
|
||||
function plugin.__trae_derive_probe(args)
|
||||
local uid = tostring((type(args) == "table" and args.uid) or "")
|
||||
return { json = { devid = traeDeriveDeviceId15(uid),
|
||||
|
||||
113
tools/codearts_sig_expected.mjs
Normal file
113
tools/codearts_sig_expected.mjs
Normal file
@ -0,0 +1,113 @@
|
||||
// 生成 CodeArts 华为云签名派生的差分期望值(供 internal/core/codearts_sign_test.go 比对)。
|
||||
//
|
||||
// 为什么需要:channels.lua 里的华为云 SDK-HMAC-SHA256 签名是**手工移植**
|
||||
// (Lua 没有 webcrypto)。签名错一处,服务端只回一句
|
||||
// `{"error_code":"APIG.0301","error_msg":"...verify ak sk signature fail"}`,
|
||||
// 看起来像"账号/权限问题",极难定位。所以拿参考实现生成期望值做逐字节差分。
|
||||
//
|
||||
// 参考真值:vendor/channel-pack/src/sign.ts 的 signRequestHuawei。
|
||||
// 这里**逐行照抄**那个函数(而非 import)—— 它依赖参考仓库的构建链,
|
||||
// 抄这 40 行更能保证"同一算法"。
|
||||
//
|
||||
// 用法(改了参考或改了移植后都要重跑):
|
||||
// node tools/codearts_sig_expected.mjs > internal/core/codearts_sig_expected.json
|
||||
|
||||
import { createHash, createHmac } from 'node:crypto'
|
||||
|
||||
const sha256Hex = (d) => createHash('sha256').update(d).digest('hex')
|
||||
const hmacSha256Hex = (key, data) => createHmac('sha256', key).update(data).digest('hex')
|
||||
|
||||
/** 照抄 sign.ts:signRequestHuawei(dateStamp 提为参数以便固定时间)。 */
|
||||
function signRequestHuawei(ak, sk, securityToken, method, urlStr, body, dateStamp) {
|
||||
const url = new URL(urlStr)
|
||||
let uri = url.pathname
|
||||
if (!uri.endsWith('/')) uri += '/'
|
||||
const query = url.search.slice(1)
|
||||
const payloadHash = sha256Hex(body)
|
||||
|
||||
const headers = new Map()
|
||||
headers.set('host', url.host)
|
||||
headers.set('x-sdk-date', dateStamp)
|
||||
headers.set('x-sdk-content-sha256', payloadHash)
|
||||
headers.set('x-security-token', securityToken)
|
||||
if (method.toUpperCase() !== 'GET') headers.set('content-type', 'application/json')
|
||||
|
||||
const signedHeaders = []
|
||||
headers.forEach((_, k) => signedHeaders.push(k))
|
||||
signedHeaders.sort()
|
||||
const headerLines = signedHeaders.map((k) => `${k}:${headers.get(k) ?? ''}`)
|
||||
const canonical = [
|
||||
method,
|
||||
uri,
|
||||
query,
|
||||
headerLines.join('\n'),
|
||||
'',
|
||||
signedHeaders.join(';'),
|
||||
payloadHash,
|
||||
].join('\n')
|
||||
const stringToSign = `SDK-HMAC-SHA256\n${dateStamp}\n${sha256Hex(new TextEncoder().encode(canonical))}`
|
||||
const signature = hmacSha256Hex(
|
||||
new TextEncoder().encode(sk),
|
||||
new TextEncoder().encode(stringToSign),
|
||||
)
|
||||
return {
|
||||
canonical,
|
||||
stringToSign,
|
||||
signature,
|
||||
signedHeaders: signedHeaders.join(';'),
|
||||
}
|
||||
}
|
||||
|
||||
// 覆盖三条真实路径(账户查询 / 活动列表带 query / 领取带 body),
|
||||
// 以及有/无 security_token 两种形态。
|
||||
const cases = [
|
||||
{
|
||||
m: 'GET',
|
||||
u: 'https://snap-access.cn-north-4.myhuaweicloud.com/snap-manager/v1/statistics/plugin',
|
||||
b: '',
|
||||
ak: 'AK1',
|
||||
sk: 'SK1',
|
||||
st: 'TOK1',
|
||||
},
|
||||
{
|
||||
m: 'GET',
|
||||
u: 'https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/delivery?channel=IDE',
|
||||
b: '',
|
||||
ak: 'AK2',
|
||||
sk: 'SK2',
|
||||
st: '',
|
||||
},
|
||||
{
|
||||
m: 'POST',
|
||||
u: 'https://snap-access.cn-north-4.myhuaweicloud.com/v1/ops/claim',
|
||||
b: '{"campaignId":"C1","channel":"IDE"}',
|
||||
ak: 'AK3',
|
||||
sk: 'SK3',
|
||||
st: 'T3',
|
||||
},
|
||||
]
|
||||
|
||||
const out = cases.map((c) => {
|
||||
const r = signRequestHuawei(
|
||||
c.ak,
|
||||
c.sk,
|
||||
c.st,
|
||||
c.m,
|
||||
c.u,
|
||||
Buffer.from(c.b, 'utf8'),
|
||||
'20261013T150405Z',
|
||||
)
|
||||
return {
|
||||
method: c.m,
|
||||
url: c.u,
|
||||
body: c.b,
|
||||
ak: c.ak,
|
||||
sk: c.sk,
|
||||
st: c.st,
|
||||
canonical: r.canonical,
|
||||
string_to_sign: r.stringToSign,
|
||||
signature: r.signature,
|
||||
signed_headers: r.signedHeaders,
|
||||
}
|
||||
})
|
||||
process.stdout.write(`${JSON.stringify(out, null, 1)}\n`)
|
||||
Reference in New Issue
Block a user