"""飞书 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