服务端协议参考 · 2026-09-26
验证协议与服务端二验
从浏览器挑战到业务放行:明确签名版本、场景绑定、一次性消费与有限期成功回执。
本页描述服务端能力升级。v2 是 HMAC 请求签名协议版本,不是 SDK v121 发布;v120 及旧固定版本的文件字节、Manifest 和当前 SDK 版本信息不因本次服务端升级而改写。
接入与信任边界
浏览器 SDK 只配置 appId,完成挑战后把 token 交给业务后端。App Secret 仅保存在可信服务端的环境变量或密钥管理系统中;不得写入浏览器、小程序、移动应用包、网页示例或日志。
- 浏览器以确定的
scene取题并验证,公共模式提交答案时保留取题返回的challengeToken。 - 业务后端从已认证用户和可信业务记录确定操作内容、业务 key 及摘要,使用 App ID 与 App Secret 调用二验。
- 只有二验通过后才提交业务动作;业务数据库仍需以业务 key 做唯一约束或事务幂等,成功回执不替代业务自身的重复提交控制。
公开鉴权仅限 /generate、/verify、/wechat-status、/channel-status,完整前缀为 /api/v1/captcha。二验接口不支持公开鉴权。
鉴权与版本选择
可信服务端始终发送 X-App-Id 和 X-App-Secret。二验默认兼容仅 secret 鉴权;应用开启 serverHmac 后,二验必须同时通过真正的 HMAC 校验。secret 模式的 /verify 始终要求签名。
| 签名版本 | 规范与接受条件 |
|---|---|
v2 | 使用本页定义的 v2 canonical,仅接受 HMAC-SHA256,校验失败绝不回退 legacy。 |
hmac | 使用原 v1 canonical,仅接受 HMAC-SHA256,校验失败绝不回退 legacy。 |
v1 或缺省版本头 | 先验原 canonical 的 HMAC;仅在 allowV1Legacy=true 且未到兼容期限时,接受历史“声明 v1、实际 legacy”的请求。二验开启 serverHmac 后禁用此兼容分支。 |
legacy | 只有 allowLegacy=true 且未到兼容期限才接受原旧算法。二验开启 serverHmac 后不接受。 |
| 未知版本 | 直接拒绝。不得把失败请求更换版本头后降级重发。 |
当前 v120 公开模式不发送签名头;配置 secret 的旧用法发送显式 legacy。其他旧版本可能声明 v1 却使用 legacy 算法,因此两项兼容开关独立配置。新浏览器接入应移除密钥,使用公开模式。
任何请求一旦带有任一签名相关请求头,就必须提供完整、有效的签名;即使是公开接口或默认允许 secret 的二验,也不会忽略错误或残缺的签名头。
v2 HMAC canonical
将以下七行按顺序用单个换行符 \n 连接。末尾不加换行,文本以 UTF-8 编码。
tac-hmac-v2
appId
METHOD
path
bodyHash
nonce
timestamp| 组成部分 | 精确定义 |
|---|---|
appId | 与 X-App-Id 完全相同。 |
METHOD | 大写 HTTP 方法;本页二验示例为 POST。 |
path | 完整接口路径,例如 /api/v1/captcha/second-verify,不含域名、查询字符串、fragment 或尾部斜杠。 |
bodyHash | 整个 JSON 请求体经下述稳定序列化后,计算 UTF-8 SHA-256,输出 64 位小写十六进制。不是原始 HTTP body 字节的摘要,也不是 businessDigest。 |
nonce | [A-Za-z0-9_-]{8,96},使用密码学安全随机数生成;在同一应用内不可重复。 |
timestamp | 13 位十进制毫秒时间戳字符串;与服务器当前时间的差值最多 300000 毫秒。 |
稳定序列化只接受 JSON 数据:对象键按 UTF-16 码元顺序递归排序,数组保序,无额外空格;标量转义和数字格式与 JavaScript JSON.stringify 一致,最大嵌套深度为 64。不接受 undefined、NaN、Infinity、循环引用或自定义对象。数字型对象键也按字符串排序,不能先构造排序对象再整体 stringify,因为整数键可能被重新排序。
计算 HMAC-SHA256(AppSecret, canonical),输出不带 = 填充的 base64url,写入 X-Tac-Signature。同时发送 X-Tac-Signature-Version: v2、X-Tac-Nonce、X-Tac-Timestamp。不另设 bodyHash 请求头。
原 v1 canonical 保持不变
[METHOD, path, JSON.stringify(body || ""), nonce, timestamp].join("|")hmac 与真正的 v1 HMAC 均使用此规范。legacy 是历史兼容算法,不应称作 HMAC;不能把 v2 签名配上 v1 或 legacy 版本头。
服务端二验字段
POST /api/v1/captcha/second-verify,Content-Type: application/json。推荐新接入总是传齐下列四个字段。
| 字段 | 约束与来源 |
|---|---|
token | 浏览器验证成功返回的 token;由业务后端转交,不可自行构造。 |
scene | 必须与取题、验证时的业务场景一致,由业务后端确定。新接入应明确传入相同的规范值,如 login。旧应用仅在 verification_require_scene=false 时可能兼容缺省;注册专用流程仍有明确场景绑定。 |
businessRequestId | 8 至 128 个字符,匹配 ^[A-Za-z0-9_.:-]{8,128}$。由业务服务端生成并持久化,同一业务操作重试时沿用。 |
businessDigest | 可信业务内容的 SHA-256 摘要,64 位十六进制;输入大小写均接受,建议输出小写。与 businessRequestId 成对提供。 |
摘要建议绑定操作类型、已认证账号、业务对象、金额及币种等由服务端确认的字段。不要直接信任客户端提交的摘要,不要把 nonce、请求时间、token 或密钥混入业务摘要,以免网络重试改变业务身份。业务摘要的序列化由接入方固定,本页示例复用稳定 JSON。
{
"token": "TOKEN_FROM_BROWSER",
"scene": "login",
"businessRequestId": "login:request:20260926-000001",
"businessDigest": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
}上面的 digest 仅展示格式;实际值必须由业务后端计算。旧请求两项业务幂等字段均不传时仍可一次性消费,但不享有成功回执重试。仅传其中一项属于无效请求。
业务回执与有限期重试
第一次成功把 token 绑定到当前应用、scene、业务 key 和 digest。之后只有全部绑定信息一致,才可在原有效期内取回成功结果。
首次成功
{"code":200,"msg":"second verification passed","data":{"replayed":false}}相同业务重试
{"code":200,"msg":"second verification passed","data":{"replayed":true}}- 相同 key、digest、token、scene 和应用:原 token 尚有效时可返回成功回执,
data.replayed=true,不会重复消费 token。 - 不同业务、不同摘要、换 token、换 scene 或换应用:不能复用原回执。同一应用中,一个业务 key 绑定后也不能通过更换 token 重新登记。
- 回执重试不续期。当前 token 通常最多有效 300 秒,普通验证还受会话结束时间限制;以实际服务端有效期为准,不承诺固定五分钟可重试。
- 网络超时或服务暂不可用时进行有限次数重试:保持业务 key、digest、token 和 scene 不变,每次重新生成 nonce、timestamp 和签名。复用 nonce 会在鉴权层被拒绝。
replayed=true表示验证码服务之前已确认同一业务请求,不代表你的订单、转账或登录动作已经执行。业务服务端必须自行持久化执行结果,并按同一业务 key 返回结果。
Node.js 18+ 可信服务端示例
下列函数只运行在业务服务端。通过环境变量 TAC_APP_ID 与 TAC_APP_SECRET 提供配置,不把真实密钥写入代码、网页或日志。调用者先完成用户认证,从业务数据库取出并固定业务 key 和业务内容,再计算 digest。
const crypto = require('node:crypto');
function stableStringify(value) {
const seen = new Set();
function encode(item, depth = 0) {
if (depth > 64) throw new Error('JSON nesting too deep');
if (item === null || typeof item === 'string' || typeof item === 'boolean') {
return JSON.stringify(item);
}
if (typeof item === 'number' && Number.isFinite(item)) return JSON.stringify(item);
if (!item || typeof item !== 'object' || seen.has(item)) throw new Error('Invalid JSON');
const prototype = Object.getPrototypeOf(item);
if (!Array.isArray(item) && prototype !== Object.prototype && prototype !== null) {
throw new Error('JSON objects only');
}
seen.add(item);
let result;
if (Array.isArray(item)) {
const values = [];
for (let i = 0; i < item.length; i++) values.push(encode(item[i], depth + 1));
result = '[' + values.join(',') + ']';
} else {
result = '{' + Object.keys(item).sort().map(key =>
JSON.stringify(key) + ':' + encode(item[key], depth + 1)).join(',') + '}';
}
seen.delete(item);
return result;
}
return encode(value);
}
const sha256 = value => crypto.createHash('sha256').update(value, 'utf8').digest('hex');
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
// trustedOperation comes from authenticated, server-owned business records.
// Keep this field set and its serialization stable across network retries.
function digestBusiness(trustedOperation) {
return sha256(stableStringify(trustedOperation));
}
async function verifyBusinessCaptcha({ token, scene, businessRequestId, businessDigest }) {
const appId = process.env.TAC_APP_ID;
const secret = process.env.TAC_APP_SECRET;
if (!appId || !secret) throw new Error('Missing server configuration');
if (typeof token !== 'string' || !token || typeof scene !== 'string' || !scene ||
typeof businessRequestId !== 'string' || !/^[A-Za-z0-9_.:-]{8,128}$/.test(businessRequestId) ||
typeof businessDigest !== 'string' || !/^[a-f0-9]{64}$/i.test(businessDigest)) {
throw new Error('Invalid verification parameters');
}
const path = '/api/v1/captcha/second-verify';
const body = { token, scene, businessRequestId, businessDigest: businessDigest.toLowerCase() };
const bodyJson = stableStringify(body);
const bodyHash = sha256(bodyJson);
for (let attempt = 0; attempt < 3; attempt++) {
const nonce = crypto.randomBytes(16).toString('hex');
const timestamp = String(Date.now());
const canonical = ['tac-hmac-v2', appId, 'POST', path, bodyHash, nonce, timestamp].join('\n');
const signature = crypto.createHmac('sha256', secret).update(canonical, 'utf8').digest('base64url');
let response, result;
try {
response = await fetch('https://api.arcveil.cn' + path, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-App-Id': appId,
'X-App-Secret': secret,
'X-Tac-Signature-Version': 'v2',
'X-Tac-Nonce': nonce,
'X-Tac-Timestamp': timestamp,
'X-Tac-Signature': signature
},
body: bodyJson,
signal: AbortSignal.timeout(5000)
});
result = await response.json();
} catch (_) {
if (attempt === 2) throw new Error('Verification transport unavailable');
await delay(250 * (attempt + 1));
continue;
}
if (response.ok && result && result.code === 200 &&
result.data && typeof result.data.replayed === 'boolean') return result.data;
const temporary = response.status >= 500 || (result && result.code === 503);
if (!temporary || attempt === 2) throw new Error('Verification was not accepted');
await delay(250 * (attempt + 1));
}
}例如从数据库读取固定业务记录后,以 { action: 'order.submit', accountId, orderId, amountMinor, currency } 计算 digestBusiness。使用该记录已持久化的 businessRequestId 调用二验;通过后在业务事务中以该 key 保证只执行一次。金额应使用整数最小货币单位或确定的十进制字符串。
跨进程或再次发起业务 HTTP 请求时也要读取同一份持久化 key 和摘要。示例内的三次重试仅处理传输失败及服务暂不可用,不把拒绝、过期、冲突、签名失败改成其他协议继续尝试。
按应用迁移与兼容期限
既有应用默认 allowLegacy=true、allowV1Legacy=true,兼容截止 2026-12-31T23:59:59.999Z(UTC,北京时间 2027-01-01 07:59:59.999)。这是固定截止时间,不会因重启或请求自动延长;应用可显式设置有限的 legacyUntil,以该应用实际策略为准。
- 盘点调用方:浏览器改为只配置 appId,签名及 App Secret 留在可信后端。保持 v120 及旧固定 SDK 文件不变,不伪造 v121 版本号。
- 后端按应用接入 v2,并验证取题 scene、二验 scene、业务 key 与摘要保持一致;覆盖响应丢失后重复二验的情况。
- 观察应用脱敏鉴权模式计数,确认旧调用完成迁移,再关闭
allowLegacy与allowV1Legacy。统计为有界异步汇总,可能延迟或丢失,不能单凭零计数判断无旧客户。 - 确认所有二验调用方均具备 HMAC 后启用
serverHmac;需要强制二验传 scene 的应用同时启用verification_require_scene。
协议策略由服务端按应用管理;不是浏览器 SDK 选项,也不是可由请求体覆盖的参数。应用策略缓存最多 5 秒,修改应用策略后需考虑此传播窗口。浏览器 SDK 版本与请求签名版本独立管理。
额度与 completed-attempt 计费
计费单位是已完成并提交的验证尝试(completed-attempt),不是取题次数、网络请求次数或仅成功次数。已完成且具备最终判定的验证尝试,无论成功或失败,按服务端计费事件处理一次。
| 情况 | 额度处理 |
|---|---|
| 取题、等待外部确认的 pending | 不计费。 |
| Redis、SQL 或验证依赖的技术故障 | 不计费,不作为真实用户答错记录。 |
| 已完成的验证尝试 | 由服务端原子计费事件决定并入账一次,不能把“失败”一概理解为免费。 |
| 同一已完成事件或成功回执的重试 | 不重复计费;二验及其回执重放本身不另计一次验证码尝试。 |
如果客户端只是没有收到响应,服务端可能已完成并入账;网络超时本身不会撤销已提交的计费事件。应查询或重试同一事务,避免重建业务请求或绕过验证。
响应与错误处理
只有 HTTP 成功且响应 JSON 的 code === 200 才允许进入业务提交阶段。当前二验路由可能以 HTTP 200 携带 code:4001 或 code:503;不能只看 HTTP 状态。成功响应的 data.replayed 表明是否返回已有成功回执。
| 结果 | 处理 |
|---|---|
invalid_request_signature、invalid_signature_version | 检查 canonical、完整请求头、版本和服务端时钟。不得降级 legacy 重试。 |
duplicate_request | 签名 nonce 已使用;网络重试必须生成新 nonce,保持同一业务绑定。 |
business_idempotency_invalid、business_idempotency_conflict | 检查字段格式以及已持久化的业务 key、digest、scene 和 token;禁止通过换 token 复用同一业务 key。 |
scene_mismatch、already_consumed、missing_or_expired | 不能放行业务。检查场景与消费记录,必要时重新进行完整验证。 |
verification_unavailable、verification_state_unavailable、second_verification_unavailable | 服务技术不可用,保持原业务绑定进行有限重试。关键存储失败不会自动切换低安全存储,也不能当作凭据过期或用户恶意。 |
应用停用、账号封禁、套餐类型限制或额度不足也会阻止调用;这些结果不应通过更换签名版本规避。