安全更換 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、英文句點及原始請求內容。更換密鑰不會改變簽署輸入或事件結構。
接收端密鑰不符,可能令真實事件被拒收。現行契約對連線錯誤、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 和比較方法。保留原始位元組,拒絕缺少簽署的請求,不要加入接受無簽署請求的備用分支。驗證後再檢查 payload 並持久化接收。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,也不保證重放遺漏 payload,因此不能宣稱更換失敗後資料會自動完整恢復。
常見問題
一個端點能設定兩個 signing_secret 嗎?
公開契約只有一個 signing_secret。本文的短期雙密鑰驗證屬於應用程式邏輯,不是 API 的雙密鑰設定。
可以暫時關閉簽署來簡化切換嗎?
不應這樣做。保持簽署啟用,缺少驗證時拒收。空密鑰會移除簽署標頭,並非過渡機制。
舊密鑰應接受多久?
沒有通用的官方時長。應根據部署與投遞檢查、在途行為及風險模型,訂立明確而有限的移除條件。
下一步與參考資料
閱讀更新 webhook 端點,先在隔離環境演練三種配置,再安排正式變更。
資料核對日期:2026-09-27。
令訊息接入變成一條穩定嘅產品管線。
先用統一嘅 API 跑通發送,再用標準事件將所有入站訊息接返去業務系統。