← 所有文章
教學

安全更換 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。

UnifyPort API

令訊息接入變成一條穩定嘅產品管線。

先用統一嘅 API 跑通發送,再用標準事件將所有入站訊息接返去業務系統。