ForcePilot/backend/package/yuxi/channels/plugins/feishu/signature.py
Kris b88c0ae29e feat(channels): 批量新增多渠道网关限界上下文基础代码与契约
新增完整的 channels 限界上下文模块,包含契约层、领域核心层、应用服务、管道编排、插件体系、基础设施组合根等全层级代码,新增飞书与微信 iLink 渠道插件基础结构,补充各类 DTO、端口协议与领域服务实现。
2026-07-02 03:22:12 +08:00

154 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""飞书 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("utf-8")
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: 允许的最大时间偏差(秒),默认 3005 分钟)。
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