← 所有文章
教學

安全輪替 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 端點,以 REST API key 驗證 PATCH /v1/webhook-endpoints/{endpoint_id} 請求。

用已核對的 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。

UnifyPort API

讓訊息接入變成一條穩定的產品管線。

先用統一 API 跑通傳送,再用標準事件把所有入站訊息接回業務系統。