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:
@ -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),
|
||||
|
||||
Reference in New Issue
Block a user