- 支付凭证隔离: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 字段)。
212 lines
9.4 KiB
Python
212 lines
9.4 KiB
Python
"""对话外审批通道(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
|