密钥与 Token 轮换

IM 系统里有四类凭据,生命周期与影响面完全不同:应用 AppSecret(服务端签名)、用户 Token(客户端长连接凭据)、回调签名密钥(服务端 → 你的回调地址)、管理端凭据(控制台/Admin API)。轮换时要分别评估“谁立即失效、影响哪些调用、如何灰度与回滚”。

四类凭据对照​

凭据用途有效期轮换入口失效后表现
AppSecretOpen API 请求签名长期(直到重置)控制台「应用与密钥」→ 重置密钥;Admin API 重置旧签名立即失效,Open API 返回 1018
用户 Token客户端 SDK connect7 天服务端签发用户 Token 接口;管理端重置用户 Token建连返回 1002;已连接会话在下一次鉴权/重连时失效
回调密钥服务端回调验签(HMAC-SHA256)长期(直到更新)控制台「回调服务」更新;Admin API 设置回调新密钥生效后旧密钥签名验不过;需要双向验证窗口
管理端凭据控制台 / Admin API 鉴权根密钥长期;子账号 token 可轮换控制台「子账号与 RBAC」;Admin API 账号接口请求返回 401/403

轮换 AppSecret​

Open API 签名 = hex(sha1(AppSecret + Nonce + Timestamp))(小写),请求头带 App-Key、Nonce、Timestamp、Signature;Timestamp 允许 300000ms 偏差,Nonce 5 分钟内不可重复。重置 AppSecret 后旧密钥立即失效(返回 1018),且不会断开客户端长连接(客户端用用户 Token,不用 AppSecret)。

  1. 确认影响面

    列出所有使用该应用 AppSecret 的服务:业务后端、定时任务、数据同步、回调重放工具。

    确认它们都能在同一时间窗口内更新配置(建议先做配置热加载,避免重启)。

  2. 预发验证新密钥

    在预发环境用新密钥调用一个只读接口(如应用信息/用户查询),确认签名算法、时钟同步(NTP)与 Nonce 生成正确。

  3. 低峰期重置并热更新

    控制台重置后,旧密钥立即失效;把新密钥写入密钥管理系统并触发热加载。

    验证:调用一个只读 Open API,code === 0 即签名正确;失败时优先检查时钟偏差与 AppSecret 是否带空格。

  4. 观察并回滚预案

    观察 Open API 错误率(1018/1019/1020)与业务成功率;若新密钥大面积失败,只能再次重置生成新密钥(不存在“恢复旧密钥”接口)。

重置不可回滚

AppSecret 只在创建与重置时返回一次,之后任何列表/详情接口都不返回明文;重置后不能恢复旧值。

因此重置前必须把新值安全落库(密钥管理系统),并确认所有调用方能在分钟级完成更新。

泄露处置优先于可用性:确认泄露时应立即重置,再逐个修复调用方。

轮换用户 Token​

场景做法影响
正常登录/续期服务端调用 user.token.issue 签发(有效期 7 天)客户端拿新 Token 后重连或下次启动使用
怀疑 Token 泄露管理端重置该用户 Token(旧 Token 立即失效)该用户所有端下次请求/重连返回 1002
封禁用户管理端封禁(1016)封禁期间不能建连与调用受保护接口
用户注销业务侧清除用户数据 + 停用 Token客户端应清理本地缓存并退出登录
JavaScript
// 服务端签发用户 Token(Open API 签名,AppSecret 只在服务端)
import { createHash, randomBytes } from 'node:crypto';

const nonce = randomBytes(8).toString("hex");
const timestamp = String(Date.now());
const signature = createHash("sha1").update(APP_SECRET + nonce + timestamp).digest("hex");

const res = await fetch(`${SERVER_URL}/im/v2/user/token/issue`, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "App-Key": APP_KEY,
    Nonce: nonce,
    Timestamp: timestamp,
    Signature: signature,
  },
  body: JSON.stringify({ userId: "alice" }),
});
const body = await res.json();
// body.data: { token, userId, expireAt };把 token 返回给客户端(不要返回 AppSecret)

回调密钥轮换​

回调请求头:X-IM-Timestamp(epoch ms)、X-IM-Nonce(8-64 字符)、X-IM-Signature = hmac_sha256(secret, timestamp + "." + nonce + "." + rawBody) 小写;允许 300 秒时间窗。回调投递 at-least-once,接收端必须用 eventId 去重。

JavaScript
// 接收端验签(Node):必须用原始 body 字节,不能 JSON.parse 后再序列化。
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(rawBody, headers, currentSecret, previousSecret) {
  const ts = Number(headers["x-im-timestamp"]);
  if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > 300000) return false;
  const nonce = headers["x-im-nonce"] || "";
  const got = String(headers["x-im-signature"] || "").toLowerCase();
  for (const secret of [currentSecret, previousSecret].filter(Boolean)) {
    const want = createHmac("sha256", secret).update(`${ts}.${nonce}.${rawBody}`).digest("hex");
    if (want.length === got.length && timingSafeEqual(Buffer.from(want), Buffer.from(got))) return true;
  }
  return false;
}
  1. 在控制台更新回调密钥后,接收端配置保留【新密钥 + 旧密钥】双密钥验证(如上),持续时间 ≥ 回调重试窗口(最长约 40 秒 + 队列延迟)。
  2. 观察回调投递记录全部成功、无验签失败日志后,移除旧密钥。
  3. 若验签失败率上升,先确认是否用了原始 body(框架自动解析 JSON 会破坏签名)。

管理端凭据与子账号​

项事实
根凭据X-Admin-Secret;环境变量 IM_ADMIN_SECRET 配置,本地默认 dev-admin(生产必须替换)
子账号角色owner / admin / operator / viewer;创建 owner 需调用者自身为 owner
子账号鉴权X-Admin-Account + X-Admin-Token;token 只在创建/轮换时返回一次
轮换更新子账号 rotateToken: true 立即失效旧 token;不可停用/降级最后一个启用中的 owner
审计管理端写操作写入操作日志(环形上限 5000 条),可按 actor/action/object/时间查询

上线前安全检查清单​

  • AppSecret 只在服务端;客户端包、前端代码、日志、错误上报中不得出现。
  • 生产环境替换默认管理密钥,并按角色最小权限分配子账号。
  • Open API 与回调都启用时间窗校验,服务器开启 NTP 同步。
  • 回调接收端使用原始 body 验签,并按 eventId 幂等。
  • 建立密钥轮换演练:至少一次在预发完整走通“重置 → 热更新 → 验证 → 观察”。
  • 记录轮换时间、操作者与影响范围(审计日志 + 变更单)。

相关链接​