Files
ModelRouter/tools/codearts_sig_expected.mjs
JianFeeeee 1586a051da 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:与参考逐平台对齐清单(登录 / 额度 / 推理链路三张表
+ 差分判据表 + 未对齐项按影响排序),未对齐项**明示**而非含糊。
2026-10-13 15:38:35 +08:00

114 lines
3.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 生成 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`)