安全轮换 UnifyPort Webhook 签名密钥
轮换 UnifyPort webhook 的 signing_secret 时,应先让所有接收实例同时验证当前密钥和新密钥,再更新端点配置。确认新投递能用新密钥通过验证后,按切换检查结果移除旧密钥。这是应用侧的分阶段部署方案:公开 API 为每个端点提供一个签名密钥,并未承诺服务端双密钥宽限期,也不保证轮换期间零丢失。
关键结论
- Webhook 签名密钥与 REST API key 分开轮换。
- 不要把空
signing_secret当作过渡步骤:它会关闭签名。 - 临时验证密钥集必须限定到对应端点和环境。
- 不要把投递重试当作部署宽限期。
先区分密钥和故障边界
X-Api-Key 用于验证你发往 UnifyPort 的请求;端点的 signing_secret 用于验证到达应用的投递。更换其中一个不会更换另一个。若要处理 REST 调用方,请阅读独立的 API key 轮换流程。
Webhook 投递参考规定:X-Device-Signature 是十六进制 HMAC-SHA256,签名输入依次为 RFC 3339 格式的 X-Device-Timestamp、一个英文句点和原始请求体。轮换只改变 HMAC 密钥,不改变签名输入或事件结构。
接收端密钥不匹配可能导致真实事件被拒收。当前契约对连接错误、HTTP 408、429 和 5xx 立即重试,没有退避;其他 4xx 不重试。因此,过早换密钥造成的 401,不能指望后续部署自动补救。返回 503 也不能构成可靠的维护期缓冲队列。
用三种配置规划切换
下表是建议的部署顺序,不是 UnifyPort 内置的轮换功能。
| 阶段 | 端点配置 | 接收端验证 |
|---|---|---|
| 准备 | 当前密钥 | 当前密钥+新密钥 |
| 切换 | 新密钥 | 当前密钥+新密钥 |
| 退役 | 新密钥 | 仅新密钥 |
开始前记录端点 ID、URL、状态、事件订阅、重试策略、接收实例和变更负责人。密钥值放在密钥管理系统,不写进变更记录。生成独立的新密钥,通过现有安全部署机制分发。
本次操作保持 URL、订阅与重试设置不变。入口迁移与身份验证轮换同时进行,会使故障难以定位。若旧密钥疑似泄露,不要采用常规重叠期:继续接受它就会延续风险。应按安全事件流程切换,并明确可用性与数据核对方案。
先更新所有接收实例,再改变发送端
在可信路由配置中实现小型、临时密钥集。不要根据未验证的 provider、account_id 或自行设想的密钥版本请求头选择密钥。公开投递头没有提供签名密钥标识符。
下面的示例函数会检查所有候选密钥,不会在第一个匹配后提前返回。它不是完整 HTTP 接收器,也不是已经执行的测试结果。keys 必须是当前端点专用的非空密钥字符串数组;maxAgeMs 是应用选定的有限正数时间容差。
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyDuringRotation({
rawBody, timestamp, signature, keys, maxAgeMs,
}) {
if (!Array.isArray(keys) || keys.length === 0 ||
keys.some(key => typeof key !== 'string' || key.length === 0) ||
!Number.isFinite(maxAgeMs) || maxAgeMs <= 0) {
throw new Error('Invalid webhook verification configuration');
}
if (!Buffer.isBuffer(rawBody) || typeof timestamp !== 'string' ||
typeof signature !== 'string' || !/^[0-9a-f]{64}$/i.test(signature)) {
return false;
}
const signedAt = Date.parse(timestamp);
if (!Number.isFinite(signedAt) ||
Math.abs(Date.now() - signedAt) > maxAgeMs) return false;
const supplied = Buffer.from(signature, 'hex');
let matches = 0;
for (const key of keys) {
const expected = createHmac('sha256', key)
.update(timestamp + '.').update(rawBody).digest();
matches |= Number(timingSafeEqual(supplied, expected));
}
return matches !== 0;
}
Node.js Crypto 参考记录了这些 HMAC 和比较方法。保留原始字节,拒绝缺少签名的请求,不要增加接受无签名请求的分支。验证通过后,再校验载荷并持久化接收。HMAC 防重放指南解释了为什么签名匹配后仍需检查时间新鲜度和处理重复事件。
在每组接收服务部署上,分别测试当前密钥、新密钥、错误密钥、被修改的请求体、缺失签名和过期时间戳。这些是建议的验收用例,并不代表代码已在你的环境中运行。
更新端点,但不要关闭签名
先用获取 webhook 端点读取当前配置。然后通过更新 webhook 端点调用 PATCH /v1/webhook-endpoints/{endpoint_id},用 REST API key 进行身份验证。
以核对过的当前 URL、启用状态、订阅和重试策略构造更新请求,在 signing_secret 中填入新密钥。不要直接把参考示例中的空密钥或停用状态复制到生产切换请求。空密钥表示关闭签名,不表示自动轮换。
更新后重新读取配置,检查 signing_enabled: true 和未变更的设置。这个标志只能证明签名已开启,不能证明正在使用哪一个密钥。向已连接的消息账号发送受控测试消息,追踪新的 message.received 投递是否通过新密钥验证、完成持久化并进入预期内部处理。不要记录任何密钥,也不要用客户内容作为测试数据。
若更新响应不明确,先保留双密钥验证,再检查配置并测试新投递。不能仅因为发出了 PATCH 请求就停用旧密钥。
明确退役条件,不虚构等待时间
公开参考没有说明已排队的投递是否保留旧签名配置,也没有规定轮换重叠期上限。retry_policy.max_attempts 是重试次数,不是宽限秒数。承诺不中断之前,应向 UnifyPort 确认不明确的在途投递行为。
以部署全部完成、新密钥的新投递、接收端错误观察结果,以及双方确认的在途处理方案作为退役条件。运维指标仅记录不含秘密的验证版本标签。接收器暂时没有流量,并不能证明旧密钥投递已经排空。
通过这些条件后,从所有接收实例和部署配置来源移除旧密钥。在隔离测试中确认旧密钥已被拒绝。重叠期必须有明确的运维终点,不能永久接受历史密钥。
如果需要回退,且旧密钥仍可信,应协调端点配置和接收端密钥集。不能在端点仍用新密钥签名时恢复只接受旧密钥的接收器。怀疑泄露时,不应恢复已暴露密钥。
适用范围与限制
UnifyPort 的非官方接口为受支持的消息账号提供统一事件;本流程保护的是其到接收服务的投递链路。它不会轮换 Telegram 或 LINE 原生凭据、平台登录会话或 API key。
先持久化经过验证的事件,再返回 2xx。普通事件重试可按事件 ID 去重;WhatsApp conversation.history 必须按契约在消息级别合并,不能仅凭顶层 ID 全局去重。UnifyPort 没有 REST 消息历史读取 API,也不保证重放遗漏载荷,因此不能宣称轮换失败后会自动恢复全部数据。
常见问题
一个端点支持两个 signing_secret 吗?
公开契约只有一个 signing_secret。本文的临时双密钥验证属于应用逻辑,不是 API 的双密钥配置。
能否暂时关闭签名来简化切换?
不要这样做。保持签名启用,缺少身份验证时拒收。空密钥会移除签名头,不是切换机制。
旧密钥应接受多久?
没有适用于所有部署的官方时长。应根据部署和投递检查、在途行为及风险模型,设定明确且有限的退役条件。
下一步与参考资料
阅读更新 webhook 端点,先在隔离环境演练三种配置,再安排生产变更。
资料核对日期:2026-09-27。
让消息接入变成一条稳定的产品管线。
先用统一 API 跑通发送,再用标准事件把所有入站消息接回业务系统。