"""对话外审批通道(feat-chv-muse-p5-C,叶子模块,纯标准库)。 接在 P3 的 ``ApprovalQueue`` 之上:队列只记 pending 待审批, 答复只走本通道,绝不走 agent 对话内容。对话里出现的“批准/ 同意/allow”字样没有任何效力——没有合法 nonce 与 HMAC 签名, 校验一律失败。 通道契约(客户端 UI 呈现是另一侧的事,本模块只给协议): - 请求载荷(``ApprovalRequest.payload()``,可外发给客户端):: {"pending_id": str, "nonce": str, "target": {...}, "reasons": [str, ...], "expires_at": float} ``target`` 只有目标描述(连接器/方法/scheme/主机/端口/路径), ``reasons`` 只有原因名;载荷里绝无凭证值与用户数据内容。 P10 扩展:可选 ``details`` 字段(支付审批的确切购买详情, 商家/商品/金额/货币,供客户端 UI 展示;不传时 payload 与 P5 完全一致)。 ``nonce`` 是随机 16 字节 hex(32 字符),每次请求唯一。 ``expires_at`` 默认请求后 15 分钟(900 秒),可注入时钟。 - 答复(``Answer``,客户端答完直接回 Sentinel):: {"pending_id": str, "nonce": str, "verdict": "allow"|"deny", "expires_at": float, "signature": str} ``signature`` 是 HMAC-SHA256,key 为通道密钥,签名覆盖 ``pending_id + nonce + verdict + expires_at``(见 ``_sign``)。 - 校验(``verify_answer``):pending 存在(本通道签发过且未 消费)、nonce 对上、未过期、签名对上、verdict 在 allow/deny 内——五项缺一即 False。答复只能用一次:``resolve_via_channel`` 成功后该 pending 的通道记录即失效,重放同一答复被拒。 - 密钥由调用方保管:通道本身不保存密钥、不把密钥写进载荷、 绝不进日志;签发请求时传 ``secret`` 只是调用方证明其持有 通道密钥(本模块不存它),签名由持有密钥的答复方计算。 - 队列接线:校验通过才调 ``ApprovalQueue.resolve``;校验失败 返回 False 且队列原样不动(pending 仍在,须重新答复)。 """ import hashlib import hmac import secrets import time from collections.abc import Iterable from cloud.chv.sentinel import ApprovalQueue # 默认审批有效期(秒):15 分钟。 DEFAULT_TTL_SECONDS = 900 _VERDICTS = ("allow", "deny") # 本通道签发过、尚未消费的请求:pending_id -> {"nonce", "expires_at"}。 # 只记 nonce 与过期,不记任何凭证值与用户数据内容。 _REQUESTS: dict[str, dict] = {} def _sign(pending_id: str, nonce: str, verdict: str, expires_at: float, secret: str) -> str: """HMAC-SHA256 签名:覆盖 pending_id + nonce + verdict + expires_at。expires_at 统一按 float 格式化,签发与校验 两侧口径一致。""" message = "\n".join((pending_id, nonce, verdict, repr(float(expires_at)))).encode("utf-8") return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest() def _target_from_record(queue_record: dict) -> dict: """队列记录里只取目标描述字段,别的什么都不带出通道。""" keys = ("connector", "method", "scheme", "host", "port", "path") return {k: queue_record[k] for k in keys if k in queue_record} class ApprovalRequest: """一次通道审批请求:pending_id + 目标描述 + 原因 + nonce + 过期时间。只含描述性字段,无凭证值、无用户数据内容。""" def __init__(self, pending_id: str, target: dict, reasons: Iterable[str] | None, nonce: str | None = None, now: float | None = None, ttl_seconds: int = DEFAULT_TTL_SECONDS, details: dict | None = None) -> None: self.pending_id = pending_id self.target = dict(target) self.reasons = list(reasons or []) self.nonce = nonce or secrets.token_hex(16) at = now if now is not None else time.time() self.expires_at = float(at) + ttl_seconds # 可选的确切详情(P10 支付审批用:商家、商品、金额、 # 货币——供客户端 UI 展示;无 details 时 payload 与 # P5 完全一致)。只含描述字段,无凭证值。 self.details = dict(details) if details is not None else None def payload(self) -> dict: """可外发给客户端的请求载荷(只含描述字段 + nonce + 过期,无密钥、无凭证值、无用户数据内容)。有 details 时带上(支付审批的确切购买详情)。""" out: dict = {"pending_id": self.pending_id, "nonce": self.nonce, "target": dict(self.target), "reasons": list(self.reasons), "expires_at": self.expires_at} if self.details is not None: out["details"] = dict(self.details) return out def request_approval(queue_record: dict, secret: str, now: float | None = None, ttl_seconds: int = DEFAULT_TTL_SECONDS, details: dict | None = None ) -> ApprovalRequest: """为一条队列记录生成通道请求并登记(nonce + 过期)。 ``secret`` 是调用方持有的通道密钥:本模块不保存它、不进 载荷、不进日志;答复方用同一密钥签名,校验时对上才算数。 ``details`` 可选(P10 支付审批的确切购买详情,进 payload 供客户端 UI 展示;不传时行为与 P5 完全一致)。 返回的 ``ApprovalRequest.payload()`` 即发给客户端的东西。 """ pending_id = queue_record["pending_id"] req = ApprovalRequest( pending_id, _target_from_record(queue_record), queue_record.get("reasons", []), now=now, ttl_seconds=ttl_seconds, details=details) _REQUESTS[pending_id] = {"nonce": req.nonce, "expires_at": req.expires_at} return req class Answer: """一次通道答复:pending_id + nonce + verdict + 过期 + HMAC 签名。verdict 只认 allow/deny,其它值校验即拒。""" def __init__(self, pending_id: str, nonce: str, verdict: str, expires_at: float, signature: str) -> None: self.pending_id = pending_id self.nonce = nonce self.verdict = verdict self.expires_at = float(expires_at) self.signature = signature def as_dict(self) -> dict: """答复的线上传输格式(与请求载荷同款纯数据 dict)。""" return {"pending_id": self.pending_id, "nonce": self.nonce, "verdict": self.verdict, "expires_at": self.expires_at, "signature": self.signature} def make_answer(request: ApprovalRequest, verdict: str, secret: str) -> Answer: """答复方(持有通道密钥的客户端)构造已签名的答复。 verdict 只认 allow/deny,其它值直接抛错、不签发。""" if verdict not in _VERDICTS: raise ValueError("verdict must be allow or deny") signature = _sign(request.pending_id, request.nonce, verdict, request.expires_at, secret) return Answer(request.pending_id, request.nonce, verdict, request.expires_at, signature) def _as_answer(answer) -> Answer | None: """Answer 对象或同款 dict 统一成 Answer;字段不全即 None (缺字段的答复直接判无效,不抛错)。""" if isinstance(answer, Answer): return answer if isinstance(answer, dict): try: return Answer(answer["pending_id"], answer["nonce"], answer["verdict"], answer["expires_at"], answer["signature"]) except (KeyError, TypeError, ValueError): return None return None def verify_answer(answer: Answer | dict, secret: str, now: float | None = None) -> bool: """校验一次答复:pending 存在(本通道签发过且未消费)、 nonce 对上、未过期、签名对上、verdict 合法——五项缺一 即 False。本函数是纯校验,不消费答复(单次性由 ``resolve_via_channel`` 在成功时消费登记来保证)。""" ans = _as_answer(answer) if ans is None or ans.verdict not in _VERDICTS: return False issued = _REQUESTS.get(ans.pending_id) if issued is None: return False if ans.nonce != issued["nonce"]: return False at = now if now is not None else time.time() if at >= issued["expires_at"] or at >= ans.expires_at: return False expected = _sign(ans.pending_id, ans.nonce, ans.verdict, ans.expires_at, secret) return hmac.compare_digest(ans.signature, expected) def resolve_via_channel(queue: ApprovalQueue, answer: Answer | dict, secret: str, now: float | None = None) -> bool: """校验通过才调队列的 resolve;校验失败返回 False 且 队列不动。成功时消费该 pending 的通道登记(答复只能用 一次),队列 resolve 失败(pending 已不在队列)则不消费、 返回 False。""" if not verify_answer(answer, secret, now=now): return False ans = _as_answer(answer) if ans is None: return False if not queue.resolve(ans.pending_id, ans.verdict): return False _REQUESTS.pop(ans.pending_id, None) return True