Files
codex-app-server/cloud/evidence_binding.py

155 lines
6.6 KiB
Python
Raw Permalink 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.
"""发布证据的新鲜度与单次使用绑定(集成 v2,代码层)。
首轮缺口(全自动 9.5 扣 0.5):发布门“重算”保证的是“不用
旧结论”,不是“证据一定新鲜”——证据体里没有时间戳/nonce
绑定,调用方把合并时同一份证据体在发布时原样重交,同样
能通过重算。本模块补这一层,但诚实边界先写清:
- 本绑定防的是**原样重放**(同一 nonce 二次提交)与**陈旧
证据**(时间戳超出窗口),不防**重盖戳**:没有签名的证据
体,调用方完全可以给旧内容盖一个新时间戳/新 nonce 再交,
本模块无法识破。防篡改/防重盖戳需要签名证据或第 5 项的
只读求证(向 GitHub 实时核对),那是另一层控制,不在本
模块冒充。
- 绑定是**调用方自愿出示**的:请求体里同时带 evidence_ts
与 evidence_nonce 才进入绑定校验;两者都不带时走旧路径
(与 #259 契约兼容,响应里标注 evidence_binding=absent,
绝不谎称已验证新鲜)。只带其一是格式错误(400),不是
“半绑定通过”。把绑定改成强制,是契约决定,须协调员/
用户在合轨时定,不在本轨悄悄改 #259。
- 单次使用语义:nonce 在**首次通过校验的提交**即被消费,
无论该次发布最终是 200 还是被其它门(未 merged、推回
阻塞)挡下——被挡的调用方下次必须取新证据重盖戳。这
是故意的:每次发布尝试都要求当次新鲜证据,而不是“一份
证据可反复试到放行”。
- nonce 存储与审计链无关:evidence_nonces 表只记 nonce、
任务 id 与时间戳,不写 audit_log、不移动 #241 链头;409
不写审计的规则不受影响。表在写路径(首次消费)才建,
读路径(校验已存在)不建表——查不到表时视为无消费记
录,不是错误。
零第三方依赖,只用标准库。时间一律用 Unix 秒(time.time),
与 full_auto.audit_record 的 ts 同口径。
"""
import time
import uuid
# 新鲜度窗口:证据时间戳距提交时刻不得超过 1 小时;未来
# 容忍 5 分钟时钟偏差,超出即视为异常(不是“更新鲜所以更好”)。
MAX_AGE_SECONDS = 3600
CLOCK_SKEW_SECONDS = 300
NONCE_TABLE_DDL = (
"CREATE TABLE IF NOT EXISTS evidence_nonces("
"nonce TEXT NOT NULL PRIMARY KEY, "
"task_id TEXT NOT NULL, "
"evidence_ts DOUBLE PRECISION NOT NULL, "
"consumed_at DOUBLE PRECISION NOT NULL)"
)
def bind_evidence(evidence, now=None, nonce=None):
"""给一份证据体盖戳:返回原证据的浅拷贝 + 时间戳/nonce。
调用方(协调员侧)在**取到证据的那一刻**调用本函数,
不要在合并时盖好、发布时原样重交——那样时间戳本身
就是旧的,会被窗口校验挡下(这正是本绑定的意义)。
nonce 缺省为 uuid4 hex;调用方自带 nonce 时须保证
全局唯一(重复 nonce 即重放)。
"""
if not isinstance(evidence, dict):
raise ValueError("evidence must be a dict")
stamped = dict(evidence)
stamped["evidence_ts"] = time.time() if now is None else now
stamped["evidence_nonce"] = nonce or uuid.uuid4().hex
return stamped
def validate_bound_evidence(evidence, now=None):
"""纯函数校验绑定证据的形状与新鲜度(不查 nonce 库)。
返回 (ok, reason):ok 为 False 时 reason 为
evidence_binding_missing / evidence_binding_incomplete /
evidence_ts_not_a_number / evidence_nonce_empty /
evidence_too_old / evidence_from_future。nonce 重放由
EvidenceNonceStore.consume 判定,不在本函数(本函数
无状态,同一份证据调两次结果相同,那不是重放判定)。
"""
if not isinstance(evidence, dict):
return False, "evidence_binding_missing"
has_ts = evidence.get("evidence_ts") is not None
has_nonce = evidence.get("evidence_nonce") is not None
if not has_ts and not has_nonce:
return False, "evidence_binding_missing"
if not (has_ts and has_nonce):
return False, "evidence_binding_incomplete"
ts = evidence.get("evidence_ts")
if isinstance(ts, bool) or not isinstance(ts, (int, float)):
return False, "evidence_ts_not_a_number"
nonce = evidence.get("evidence_nonce")
if not isinstance(nonce, str) or not nonce.strip():
return False, "evidence_nonce_empty"
moment = time.time() if now is None else now
if ts > moment + CLOCK_SKEW_SECONDS:
return False, "evidence_from_future"
if moment - ts > MAX_AGE_SECONDS:
return False, "evidence_too_old"
return True, None
def binding_presence(evidence):
"""绑定出示状态:bound(成对出示)/ partial(只带其一)/
absent(两者皆无)。服务端据此决定走绑定校验、报 400
还是走旧路径(并标注 absent,绝不谎称已验证)。"""
if not isinstance(evidence, dict):
return "absent"
has_ts = evidence.get("evidence_ts") is not None
has_nonce = evidence.get("evidence_nonce") is not None
if has_ts and has_nonce:
return "bound"
if has_ts or has_nonce:
return "partial"
return "absent"
class EvidenceNonceStore:
"""nonce 单次使用存储(DB 连接由调用方传入并负责提交)。
consume 成功返回 True(首次见此 nonce);nonce 已存在
返回 False(重放)。表不存在时 consume 先建表(写路径
才建);has() 在表不存在时返回 False,不建表。
"""
def __init__(self, conn):
self.conn = conn
def ensure_table(self):
self.conn.execute(NONCE_TABLE_DDL)
def has(self, nonce):
try:
row = self.conn.execute(
"SELECT nonce FROM evidence_nonces WHERE nonce = ?",
(nonce,)).fetchone()
except Exception:
return False
return row is not None
def consume(self, task_id, nonce, evidence_ts, consumed_at=None):
"""消费一个 nonce:首次 True;已存在 False(不覆盖原行)。
调用方须在 consume 后提交(本函数不 commit:服务端
在自己的连接生命周期里提交,与其余写操作保持一致)。
"""
self.ensure_table()
if self.has(nonce):
return False
self.conn.execute(
"INSERT INTO evidence_nonces(nonce, task_id, evidence_ts,"
" consumed_at) VALUES(?, ?, ?, ?)",
(nonce, task_id, evidence_ts,
time.time() if consumed_at is None else consumed_at),
)
return True