本次提交包含多项代码优化与规范修正: 1. 文档与注释优化:修正注释术语、补充注解与FR编号 2. 代码格式调整:统一空格、换行与缩进规范 3. 类型与接口完善:补充__all__导出、修正返回类型注解 4. 错误处理增强:新增领域错误类与校验逻辑 5. 依赖与导入调整:修复路径引用、统一时区导入 6. 协议与契约更新:完善接口文档与一致性注解
154 lines
5.3 KiB
Python
154 lines
5.3 KiB
Python
"""飞书 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:
|
||
raw = f"{timestamp}{nonce}{encrypt_key}{body}".encode()
|
||
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
|