Files
codex-app-server/cloud/chv/approval_channel.py
T
host b55b49b358 feat(chv): 对齐 Muse P10 支付安全——凭证隔离 + 结账检测 + 每次购买人工审批 + 一次性卡号
- 支付凭证隔离:PaymentCredentialVault(只活内存、白名单
  stripe-link/shop-pay、只存不透明 id + 掩码标签、原始卡号
  PAN 拒绝入库、按次释放绑定商家/金额/货币、单次 + TTL)
- 结账页检测:detect_checkout(URL + 无障碍树信号,确定性
  规则、不读原始 DOM、与 P4 detect_form_risk 同输入形状)
- 每次购买人工审批:PaymentGate 经 P5 对话外通道发审批,
  payload 含确切购买详情;完成时先验签、再验详情摘要
  (篡改即拒且审批保持待处理)、最后单次消费答复
- 一次性卡号:OneTimeCard 绑定商家/金额/货币/有效期,单次
  兑现;SimulatedWalletBackend 沙箱钱包(假 PAN 只在后端
  私有内存、charge 只记账不联网、取消即 void)
- 限额:SpendLimits 三档 minor units 比对,限额不可用时
  不编造上限(limits_checked=False)
- 审计:PaymentAudit 只留允许字段,秘密字段名丢弃 + redacted
- 审批通道最小扩展:ApprovalRequest 增加可选 details 字段,
  不传时 payload 与 P5 完全一致
- 真实支付与扣款不做:全部沙箱验证,真实钱包集成待用户
  另行批准;新测试 79 项,全量 cloud 套件 2037 OK

round-12 集成说明:本提交为 acf98f8 的 cherry-pick(只含 P10
自有 diff,基线 25bcd35 已含的 P3/P5 内容未重复合入;
approval_channel.py 仅加可选 details 字段)。
2026-11-07 00:09:55 +00:00

212 lines
9.4 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.
"""对话外审批通道(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