"""钉钉 HTTP 模式签名校验。 实现钉钉 HTTP 模式 Webhook 回调的签名验签(设计方案 §3.3)。钉钉 HTTP 模式签名规则: 1. 拼接 ``timestamp + "\\n" + sign_secret`` 2. 计算 HMAC-SHA256 拼接字符串,Base64 编码 3. 与回调中的 ``sign`` 字段比对(恒定时间比较防时序攻击) 4. ``timestamp`` 与当前时间差超过 60 分钟拒绝(防重放) Stream 模式由 SDK 内置 TLS 加密,无需签名校验(``verifySignature`` 直接 返回 ``true``)。 依赖方向:仅标准库 + ``yuxi.channels.contract.errors.*``,不污染框架层。 """ from __future__ import annotations import base64 import hashlib import hmac import time from typing import Any from yuxi.channels.contract.errors import ValidationError from ._constants import HTTP_SIGN_TIMESTAMP_TOLERANCE_SEC def compute_signature(timestamp: int, sign_secret: str) -> str: """计算钉钉 HTTP 模式签名。 钉钉官方签名规则(对齐开放平台文档): - ``string_to_sign = f"{timestamp}\\n{sign_secret}"`` - ``hmac.new(key=sign_secret, msg=string_to_sign, digestmod=sha256)`` Args: timestamp: 回调中的 ``timestamp`` 字段(毫秒级时间戳)。 sign_secret: HTTP 模式签名密钥(``sign_secret`` 配置项)。 Returns: Base64 编码的 HMAC-SHA256 签名字符串。 """ string_to_sign = f"{timestamp}\n{sign_secret}" hmac_code = hmac.new( key=sign_secret.encode("utf-8"), msg=string_to_sign.encode("utf-8"), digestmod=hashlib.sha256, ).digest() return base64.b64encode(hmac_code).decode("utf-8") def verify_signature( timestamp: int, sign: str, sign_secret: str, *, now_ts: int | None = None, ) -> bool: """校验钉钉 HTTP 模式签名。 Args: timestamp: 回调中的 ``timestamp`` 字段(毫秒级时间戳)。 sign: 回调中的 ``sign`` 字段(待校验签名)。 sign_secret: HTTP 模式签名密钥。 now_ts: 当前时间戳(毫秒),用于测试注入;为 ``None`` 时取 ``int(time.time() * 1000)``。 Returns: ``True`` 表示签名通过;``False`` 表示签名失败或时间戳过期。 本函数 **不抛异常**,调用方据返回值决定是否终止入站管道。 """ if not sign or not sign_secret or not timestamp: return False now = now_ts if now_ts is not None else int(time.time() * 1000) if abs(now - timestamp) > HTTP_SIGN_TIMESTAMP_TOLERANCE_SEC * 1000: return False expected = compute_signature(timestamp, sign_secret) return hmac.compare_digest(expected, sign) def extract_timestamp_sign(payload: dict[str, Any]) -> tuple[int, str]: """从钉钉 HTTP 模式回调 payload 提取 timestamp 与 sign。 Args: payload: 钉钉 HTTP 模式回调的完整 payload。 Returns: ``(timestamp, sign)`` 元组。字段缺失时返回 ``(0, "")``。 Raises: ValidationError: timestamp 字段存在但非整数。 """ timestamp = payload.get("timestamp", 0) if timestamp and not isinstance(timestamp, int): try: timestamp = int(timestamp) except (TypeError, ValueError) as exc: raise ValidationError( field="timestamp", message=f"timestamp must be integer: {timestamp}", ) from exc sign = payload.get("sign", "") or "" return int(timestamp), sign __all__ = [ "compute_signature", "verify_signature", "extract_timestamp_sign", ]