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:
JianFeeeee
2026-10-13 15:38:35 +08:00
parent 41ff6d1be3
commit 1586a051da
5 changed files with 542 additions and 37 deletions

View File

@ -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
-- 华为云 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
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,
},
}
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),