Arcveil开发文档

服务端协议参考 · 2026-09-26

验证协议与服务端二验

从浏览器挑战到业务放行:明确签名版本、场景绑定、一次性消费与有限期成功回执。

本页描述服务端能力升级。v2 是 HMAC 请求签名协议版本,不是 SDK v121 发布;v120 及旧固定版本的文件字节、Manifest 和当前 SDK 版本信息不因本次服务端升级而改写。

接入与信任边界

浏览器 SDK 只配置 appId,完成挑战后把 token 交给业务后端。App Secret 仅保存在可信服务端的环境变量或密钥管理系统中;不得写入浏览器、小程序、移动应用包、网页示例或日志。

  1. 浏览器以确定的 scene 取题并验证,公共模式提交答案时保留取题返回的 challengeToken。
  2. 业务后端从已认证用户和可信业务记录确定操作内容、业务 key 及摘要,使用 App ID 与 App Secret 调用二验。
  3. 只有二验通过后才提交业务动作;业务数据库仍需以业务 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},使用密码学安全随机数生成;在同一应用内不可重复。
timestamp13 位十进制毫秒时间戳字符串;与服务器当前时间的差值最多 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 时可能兼容缺省;注册专用流程仍有明确场景绑定。
businessRequestId8 至 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,以该应用实际策略为准。

  1. 盘点调用方:浏览器改为只配置 appId,签名及 App Secret 留在可信后端。保持 v120 及旧固定 SDK 文件不变,不伪造 v121 版本号。
  2. 后端按应用接入 v2,并验证取题 scene、二验 scene、业务 key 与摘要保持一致;覆盖响应丢失后重复二验的情况。
  3. 观察应用脱敏鉴权模式计数,确认旧调用完成迁移,再关闭 allowLegacy 与 allowV1Legacy。统计为有界异步汇总,可能延迟或丢失,不能单凭零计数判断无旧客户。
  4. 确认所有二验调用方均具备 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服务技术不可用,保持原业务绑定进行有限重试。关键存储失败不会自动切换低安全存储,也不能当作凭据过期或用户恶意。

应用停用、账号封禁、套餐类型限制或额度不足也会阻止调用;这些结果不应通过更换签名版本规避。