2026-07-02 03:22:12 +08:00
|
|
|
|
"""飞书 Webhook 签名校验与事件解密模块。
|
|
|
|
|
|
|
|
|
|
|
|
提供以下能力:
|
|
|
|
|
|
|
|
|
|
|
|
- ``verify_webhook_signature``: 校验 Webhook 签名(SHA256 + 恒定时间比对)。
|
|
|
|
|
|
- ``is_timestamp_valid``: 校验时间戳是否在允许的时钟偏移窗口内(防重放)。
|
|
|
|
|
|
- ``decrypt_event``: AES-256-CBC 解密飞书事件加密内容。
|
|
|
|
|
|
- ``extract_challenge``: 识别 URL 验证 challenge 请求。
|
|
|
|
|
|
|
|
|
|
|
|
依赖方向:仅依赖标准库、``cryptography``(可选)与 ``yuxi.channels.contract``
|
|
|
|
|
|
错误类型,不污染框架层。``cryptography`` 缺失时 ``decrypt_event`` 抛
|
|
|
|
|
|
``DependencyError`` 显式暴露缺失依赖,避免静默降级。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import base64
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import hmac
|
|
|
|
|
|
import json
|
|
|
|
|
|
import time
|
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
|
|
from yuxi.channels.contract.errors import DependencyError, ValidationError
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from cryptography.hazmat.backends import default_backend
|
|
|
|
|
|
from cryptography.hazmat.primitives import padding as sympad
|
|
|
|
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
|
|
|
|
|
|
|
|
|
|
_HAS_CRYPTOGRAPHY = True
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
_HAS_CRYPTOGRAPHY = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def verify_webhook_signature(
|
|
|
|
|
|
timestamp: str,
|
|
|
|
|
|
nonce: str,
|
|
|
|
|
|
encrypt_key: str,
|
|
|
|
|
|
body: str,
|
|
|
|
|
|
signature: str,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""校验飞书 Webhook 签名。
|
|
|
|
|
|
|
|
|
|
|
|
签名算法:``SHA256(timestamp + nonce + encrypt_key + body)``,
|
|
|
|
|
|
使用 ``hmac.compare_digest`` 进行恒定时间比对以防止时序攻击。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
timestamp: 时间戳字符串。
|
|
|
|
|
|
nonce: 随机字符串。
|
|
|
|
|
|
encrypt_key: 配置的 Encrypt Key。
|
|
|
|
|
|
body: 原始请求体字符串。
|
|
|
|
|
|
signature: 请求头中携带的签名(十六进制小写)。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
签名校验结果,``True`` 表示通过。**不抛异常**:输入类型异常或
|
|
|
|
|
|
编码失败时返回 ``False``,由调用方决定是否记录日志或抛出
|
|
|
|
|
|
``ValidationError``。
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2026-07-03 19:18:13 +08:00
|
|
|
|
raw = f"{timestamp}{nonce}{encrypt_key}{body}".encode()
|
2026-07-02 03:22:12 +08:00
|
|
|
|
expected = hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
except (TypeError, UnicodeEncodeError, AttributeError):
|
|
|
|
|
|
return False
|
|
|
|
|
|
return hmac.compare_digest(expected, signature)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_timestamp_valid(timestamp: str, max_skew_seconds: int = 300) -> bool:
|
|
|
|
|
|
"""校验时间戳是否在允许的时钟偏移窗口内(防重放)。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
timestamp: 时间戳字符串(秒级)。
|
|
|
|
|
|
max_skew_seconds: 允许的最大时间偏差(秒),默认 300(5 分钟)。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
``True`` 表示时间戳有效。解析失败或超出窗口返回 ``False``,
|
|
|
|
|
|
**不抛异常**。
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
ts = int(timestamp)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return False
|
|
|
|
|
|
skew = abs(int(time.time()) - ts)
|
|
|
|
|
|
return skew <= max_skew_seconds
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def decrypt_event(encrypt_payload: str, encrypt_key: str) -> dict[str, Any]:
|
|
|
|
|
|
"""AES-256-CBC 解密飞书事件加密内容。
|
|
|
|
|
|
|
|
|
|
|
|
Key 派生:``SHA256(encrypt_key)`` 取前 32 字节作为 AES-256 key。
|
|
|
|
|
|
加密内容为 Base64 编码,解码后取前 16 字节作为 IV,剩余部分为密文。
|
|
|
|
|
|
解密后去除 PKCS7 padding,返回 JSON 解析后的 dict。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
encrypt_payload: Base64 编码的加密内容字符串。
|
|
|
|
|
|
encrypt_key: 配置的 Encrypt Key。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
解密后的 JSON dict。
|
|
|
|
|
|
|
|
|
|
|
|
Raises:
|
|
|
|
|
|
DependencyError: ``cryptography`` 依赖不可用,``cause`` 保留
|
|
|
|
|
|
``ImportError`` 上下文,调用方应明确感知依赖缺失而非静默降级。
|
|
|
|
|
|
ValidationError: 解密失败(Base64 解码失败、padding 错误、JSON
|
|
|
|
|
|
解析失败等),``field`` 为 ``encrypt_payload``,``message``
|
|
|
|
|
|
固定为 ``"decrypt_failed"``;原始异常通过 ``raise ... from exc``
|
|
|
|
|
|
链式保留,供调用方 ``__cause__`` 追踪。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not _HAS_CRYPTOGRAPHY:
|
|
|
|
|
|
raise DependencyError(
|
|
|
|
|
|
dep="cryptography",
|
|
|
|
|
|
cause=ImportError("cryptography package is required for event decryption"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
key = hashlib.sha256(encrypt_key.encode("utf-8")).digest()[:32]
|
|
|
|
|
|
payload = base64.b64decode(encrypt_payload)
|
|
|
|
|
|
iv = payload[:16]
|
|
|
|
|
|
ciphertext = payload[16:]
|
|
|
|
|
|
cipher = Cipher(
|
|
|
|
|
|
algorithms.AES(key),
|
|
|
|
|
|
modes.CBC(iv),
|
|
|
|
|
|
backend=default_backend(),
|
|
|
|
|
|
)
|
|
|
|
|
|
decryptor = cipher.decryptor()
|
|
|
|
|
|
padded = decryptor.update(ciphertext) + decryptor.finalize()
|
|
|
|
|
|
unpadder = sympad.PKCS7(128).unpadder()
|
|
|
|
|
|
plain = unpadder.update(padded) + unpadder.finalize()
|
|
|
|
|
|
return json.loads(plain.decode("utf-8"))
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
field="encrypt_payload",
|
|
|
|
|
|
message="decrypt_failed",
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extract_challenge(payload: dict[str, Any]) -> str | None:
|
|
|
|
|
|
"""识别 URL 验证 challenge 请求。
|
|
|
|
|
|
|
|
|
|
|
|
飞书在配置 Webhook 时会发送一个含 ``challenge`` 字段的请求用于验证 URL,
|
|
|
|
|
|
本函数从中提取 challenge 值,便于调用方原样回包完成验证握手。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
payload: Webhook 请求体解析后的 dict。
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
challenge 字符串;若 ``payload`` 不含 ``challenge`` 字段或类型不符
|
|
|
|
|
|
则返回 ``None``。
|
|
|
|
|
|
"""
|
|
|
|
|
|
challenge = payload.get("challenge")
|
|
|
|
|
|
if isinstance(challenge, str):
|
|
|
|
|
|
return challenge
|
|
|
|
|
|
return None
|