密钥与 Token 轮换
IM 系统里有四类凭据,生命周期与影响面完全不同:应用 AppSecret(服务端签名)、用户 Token(客户端长连接凭据)、回调签名密钥(服务端 → 你的回调地址)、管理端凭据(控制台/Admin API)。轮换时要分别评估“谁立即失效、影响哪些调用、如何灰度与回滚”。
四类凭据对照
| 凭据 | 用途 | 有效期 | 轮换入口 | 失效后表现 |
|---|---|---|---|---|
| AppSecret | Open API 请求签名 | 长期(直到重置) | 控制台「应用与密钥」→ 重置密钥;Admin API 重置 | 旧签名立即失效,Open API 返回 1018 |
| 用户 Token | 客户端 SDK connect | 7 天 | 服务端签发用户 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)。
- 确认影响面
列出所有使用该应用 AppSecret 的服务:业务后端、定时任务、数据同步、回调重放工具。
确认它们都能在同一时间窗口内更新配置(建议先做配置热加载,避免重启)。
- 预发验证新密钥
在预发环境用新密钥调用一个只读接口(如应用信息/用户查询),确认签名算法、时钟同步(NTP)与 Nonce 生成正确。
- 低峰期重置并热更新
控制台重置后,旧密钥立即失效;把新密钥写入密钥管理系统并触发热加载。
验证:调用一个只读 Open API,
code === 0即签名正确;失败时优先检查时钟偏差与 AppSecret 是否带空格。 - 观察并回滚预案
观察 Open API 错误率(1018/1019/1020)与业务成功率;若新密钥大面积失败,只能再次重置生成新密钥(不存在“恢复旧密钥”接口)。
AppSecret 只在创建与重置时返回一次,之后任何列表/详情接口都不返回明文;重置后不能恢复旧值。
因此重置前必须把新值安全落库(密钥管理系统),并确认所有调用方能在分钟级完成更新。
泄露处置优先于可用性:确认泄露时应立即重置,再逐个修复调用方。
轮换用户 Token
| 场景 | 做法 | 影响 |
|---|---|---|
| 正常登录/续期 | 服务端调用 user.token.issue 签发(有效期 7 天) | 客户端拿新 Token 后重连或下次启动使用 |
| 怀疑 Token 泄露 | 管理端重置该用户 Token(旧 Token 立即失效) | 该用户所有端下次请求/重连返回 1002 |
| 封禁用户 | 管理端封禁(1016) | 封禁期间不能建连与调用受保护接口 |
| 用户注销 | 业务侧清除用户数据 + 停用 Token | 客户端应清理本地缓存并退出登录 |
// 服务端签发用户 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 去重。
// 接收端验签(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;
}- 在控制台更新回调密钥后,接收端配置保留【新密钥 + 旧密钥】双密钥验证(如上),持续时间 ≥ 回调重试窗口(最长约 40 秒 + 队列延迟)。
- 观察回调投递记录全部成功、无验签失败日志后,移除旧密钥。
- 若验签失败率上升,先确认是否用了原始 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 幂等。
- 建立密钥轮换演练:至少一次在预发完整走通“重置 → 热更新 → 验证 → 观察”。
- 记录轮换时间、操作者与影响范围(审计日志 + 变更单)。
相关链接
- 鉴权与签名:签名算法与请求头
- 安全与合规:数据最小化与传输安全
- 控制台:应用与密钥
- 控制台:子账号与 RBAC
- 服务端回调:验签、重试与投递日志