UDI Lens

驗證簽章

每個請求都帶三個標頭,採用 Standard Webhooks 規範:

標頭說明
webhook-id事件 ID;重試時不變
webhook-timestamp送出時間(Unix 秒);每次重試更新
webhook-signature一或多組 v1,<base64>,以空白分隔

演算法

signed_content = webhook-id + "." + webhook-timestamp + "." + 原始 body
key            = base64_decode(API Key 去掉 "whsec_" 前綴)
signature      = "v1," + base64(HMAC-SHA256(key, signed_content))

驗證時:

  1. 三個標頭都必須存在
  2. webhook-timestamp 與現在時間相差不得超過 5 分鐘(防重放)
  3. 計算期望簽章,與 webhook-signature 中的任一組做常數時間比較
  4. 一定要用原始 body。先 JSON.parse 再序列化,空白或欄位順序改變就會驗證失敗

API Key 不會出現在請求中。不要要求我們以 Authorization 標頭傳送金鑰;明文標頭容易被反向代理、WAF 或監控工具寫進日誌。

金鑰輪替

在 App 或入口網站輪替後,24 小時內 webhook-signature 會同時帶新舊兩組簽章。請在這段期間把系統更新為新金鑰。

使用官方函式庫

Standard Webhooks 提供多種語言的函式庫,可直接使用:

import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.UDILENS_API_KEY); // whsec_...
const event = wh.verify(rawBody, headers); // 驗證失敗會拋出錯誤

各語言範例

Python(Flask)

import base64, hashlib, hmac, time

def verify(api_key: str, headers, raw_body: bytes) -> bool:
    msg_id, ts, sigs = headers.get("webhook-id"), headers.get("webhook-timestamp"), headers.get("webhook-signature")
    if not (msg_id and ts and sigs) or abs(time.time() - int(ts)) > 300:
        return False
    key = base64.b64decode(api_key.removeprefix("whsec_"))
    expected = "v1," + base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + raw_body, hashlib.sha256).digest()).decode()
    return any(hmac.compare_digest(s, expected) for s in sigs.split(" "))

C#(.NET 8)

static bool Verify(string apiKey, string id, string ts, string sigs, string rawBody)
{
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(ts)) > 300) return false;
    using var hmac = new HMACSHA256(Convert.FromBase64String(apiKey["whsec_".Length..]));
    var expected = Encoding.UTF8.GetBytes("v1," + Convert.ToBase64String(
        hmac.ComputeHash(Encoding.UTF8.GetBytes($"{id}.{ts}.{rawBody}"))));
    return sigs.Split(' ').Any(s => CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(s), expected));
}

Java 17

static boolean verify(String apiKey, String id, String ts, String sigs, String body) throws Exception {
    if (Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(ts)) > 300) return false;
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(Base64.getDecoder().decode(apiKey.substring(6)), "HmacSHA256"));
    byte[] expected = ("v1," + Base64.getEncoder().encodeToString(
        mac.doFinal((id + "." + ts + "." + body).getBytes(StandardCharsets.UTF_8)))).getBytes(StandardCharsets.UTF_8);
    for (String s : sigs.split(" ")) if (MessageDigest.isEqual(s.getBytes(StandardCharsets.UTF_8), expected)) return true;
    return false;
}

測試向量

用這組資料確認你的實作:

項目值
API Keywhsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-idmsg_p5jXN8AQM9LWM0D4loKWxJek
webhook-timestamp1614265330
body{"test": 2432232314}
期望簽章v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=

(測試向量的時間戳很舊,驗證時請暫時略過時間檢查。)也可以在線上收件器的「簽章練習」貼上資料逐步檢查。